做旅游类项目这些年我发现一个很尴尬的现象大量景点网站做得跟黄页一样景点列表摆在那用户翻两页就关了。真正让用户留下来、让平台有价值的东西其实是“推荐”——用户不知道去哪儿玩的时候系统能不能给出他真正感兴趣的答案。这个项目就是干这件事的基于nodejsvueexpress的旅游景点推荐系统。后端用Node.js跑Express提供接口和推荐逻辑前端用Vue做单页应用中间通过RESTful API串联。系统解决的问题很明确用户初次打开不知道逛什么平台不知道给谁推什么冷启动阶段一片空白。它适合刚开始做全栈项目的开发者、想入门推荐系统但不想一头扎进Spark和Python重型方案的人也适合作为毕业设计或者个人作品集的实战项目。我这次把完整思路、代码、坑都拆开讲尽量让一个零基础的人也能照着做出来。1. 项目概述与整体设计思路1.1 这个推荐系统到底解决什么问题先聊一个真实的场景。你在某旅游平台搜“杭州”出来的结果是断桥、灵隐寺、西湖每个都标着5A景区。但你可能带着孩子、或者喜欢人文拍照、或者就想找个冷门地方待一下午——一套静态列表根本满足不了这些需求。推荐系统的价值不是把热门景点排一遍而是根据用户的行为和历史偏好把匹配度最高的景点推到他面前。所以这个系统我拆成三个核心环节用户怎么表达偏好通过浏览、收藏、评分这些行为隐式或显式地表达。系统怎么判断相似景点之间靠标签自然风光、历史古迹、亲子、美食等建立关联。怎么生成推荐结果用混合策略行为少的走内容匹配行为多的走协同过滤。这套设计不算复杂但它是能跑通业务闭环的。用户进来看到热门列表浏览了几个景点点了收藏再刷新推荐页结果开始变了。这个“变化”就是推荐系统的存在感。1.2 技术选型为什么是nodejsvueexpress选这套技术栈不是拍脑袋而是因为它在一个非常合适的复杂度区间。Node.js做后端的好处是前后端统一语言你不需要在JavaScript和Java之间来回切换思维。Express是Node生态里最经典的Web框架路由、中间件、请求处理都足够轻量。Vue则负责前端视图层组件化的写法对小型项目来说开发效率极高。有人会问推荐系统用Python不是更成熟吗确实scikit-learn里有现成算法但那是另一条路。这个项目的推荐逻辑完全可以自己实现不依赖重型依赖库跑在Node里毫无压力。而且对于教学或者毕设场景面试官/老师更看重的是你懂不懂原理、能不能手写一个计算过程而不是会不会调包。整套项目的前后端结构是这样的travel-recommend/ ├── client/ # Vue前端工程 │ ├── src/ │ │ ├── views/ # 页面组件 │ │ ├── router/ # 路由配置 │ │ └── api/ # 接口请求封装 └── server/ # Express后端 ├── routes/ # 路由定义 ├── models/ # 数据模型 └── server.js # 入口文件1.3 功能模块与页面结构拆解推荐系统不是只有一个推荐列表它需要一套完整的功能闭环。我列一下这个项目的功能模块模块功能说明对应页面/接口景点浏览按城市、分类筛选景区列表首页 / GET /api/spots景点详情展示介绍、标签、评分详情页 / GET /api/spots/:id用户行为浏览、收藏、评分POST /api/behaviors推荐中心个性化推荐结果推荐页 / GET /api/recommend/:userId热门榜单无行为时的兜底推荐首页热门 / GET /api/spots/hot页面结构也简单首页是景点列表详情页展示单点信息推荐页呈现个性化结果个人中心看自己的行为记录。页面不多但每一块都对应推荐系统的数据输入或输出。2. 开发环境搭建与避坑指南2.1 Node.js安装与环境变量配置这个步骤看起来基础实际上拦住了不少人。很多同学卡在node不是内部或外部命令这一步十有八九是环境变量没配好。我建议装LTS版本别追最新的尝鲜版稳定性优先。Windows下的安装包是.msi一步步点Next就行安装目录建议保持默认比如C:\Program Files\nodejs这会直接影响后面的环境变量配置。装完后需要验证两个东西node -v npm -v如果提示找不到命令手动配置环境变量。在系统变量Path里加入Node.js安装目录比如C:\Program Files\nodejs。这里有个细节Node.js在安装时会自动把路径写进用户变量的Path但如果你的系统开了UAC或者用了非管理员账户可能不会生效手动加一次最保险。还有坑要提前说Express 4版本的req.body默认是undefined需要装body-parser中间件。Express 4.16之后这个功能内置了直接app.use(express.json())就行不用额外装。不同版本API有差异踩过才知道多疼。2.2 创建Vue前端工程Vue项目初始化方式推荐用npm init vuelatest这个命令会走Vite构建工具速度比Webpack时代的vue create快很多模板也更清爽。npm init vuelatest # 按提示输入项目名选上 Router、Pinia cd client npm install npm run dev项目跑起来后默认端口5173。开发和联调阶段我会用Vite的代理转发API请求前端请求写/api/xxx代理转发到Express的3000端口这样能绕开跨域问题。这一步配置放到后面API章节讲。Vue的组件结构我尽量精简src/ ├── views/ │ ├── HomeView.vue # 景点列表 │ ├── DetailView.vue # 景点详情 │ ├── RecommendView.vue # 推荐结果 │ └── ProfileView.vue # 个人中心 ├── router/index.js └── api/index.js2.3 搭建Express后端骨架后端我习惯用手动初始化而不是脚手架因为Express项目不大几行代码就完成了脚手架反而多一层封装干扰理解。mkdir server cd server npm init -y npm install express mysql2 cors一个最小可用的后端是这样的const express require(express); const cors require(cors); const app express(); app.use(cors()); app.use(express.json()); app.get(/, (req, res) { res.json({ message: api is running }); }); app.listen(3000, () { console.log(server running at http://localhost:3000); });然后node server.js就好。注意监听端口别和前端Vite的5173冲突一个3000一个5173分工明确。2.4 npm脚本执行权限报错处理这个坑我当年踩过相信你也早晚会遇到。Windows下用PowerShell跑npm命令报错长这样npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本原因很简单PowerShell的执行策略默认是Restricted不允许运行.ps1脚本。npm的npm.ps1就是一个PowerShell脚本文件自然被拦了。解决方案有两种方案一改执行策略推荐Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser按Y确认就行。这个命令只对当前用户生效不会影响系统其他账户。方案二绕开PowerShell在cmd命令行里跑npm或者在VS Code里把默认终端切成cmd。两种都行但治标不治本下次用PowerShell还会遇到。顺手提一句执行策略改成RemoteSigned意味着本地脚本可运行远程下载的脚本需要签名这是安全设计不要直接改成Unrestricted没必要拿机器的安全性开玩笑。3. 推荐系统核心设计与算法实现3.1 推荐思路混合推荐策略推荐系统这个名词看起来高大上拆开看无非三类基于内容的推荐、协同过滤、混合推荐。这里我用的就是混合策略不过度设计。新用户没行为走热门兜底按浏览量和评分排序。有少量行为基于内容的推荐核心是把用户行为映射成标签偏好找标签匹配度高的景点。有较多行为协同过滤核心是找相似用户或相似物品推你没碰过的东西。为什么不做纯协同过滤因为冷启动问题。一个刚注册的用户一条行为记录都没有协同过滤算不出来只能推热门。这是所有推荐系统都绕不开的先天问题处理策略就是在算法前面加一个行为量阈值。我实测下来的经验是行为记录少于5条内容推荐就够了超过10条协同过滤效果明显好。3.2 景点标签与用户偏好建模数据建模是整个推荐质量的地基。景点数据我设计了tags字段用逗号分隔多个标签比如“西湖湖泊,历史,摄影,亲子”“故宫历史,文化,建筑”。标签体系不要太多太杂控制在8-10个核心标签以内不然向量稀疏算相似度全是为零没有参考价值。用户偏好怎么建我采用行为加权的方式行为类型权重含义浏览(view)1兴趣微弱收藏(collect)3兴趣明显评分(rating)5兴趣强烈用户对某个标签的偏好分就是所有行为权重的累加。比如用户浏览了“西湖”湖泊1,历史1收藏了“故宫”历史3,文化3那他的偏好向量就是{湖泊:1, 历史:4, 文化:3}。这个向量就是后面推荐打分的数据源。3.3 基于物品的协同过滤实现为什么选基于物品而不是基于用户因为项目初期用户量小基于用户的协同过滤矩阵极度稀疏算出来的相似度全是噪音。基于物品的协同过滤稳定性更好景点数量远少于用户数量标签相似度可以直接计算可解释性也强“因为你喜欢故宫所以推荐颐和园”听起来就很合理。物品相似度的计算我用余弦相似度两个景点的标签向量越接近相似度越高。代码实现不复杂function computeSimilarity(spotA, spotB) { const tagsA spotA.tags.split(,); const tagsB spotB.tags.split(,); const union new Set([...tagsA, ...tagsB]); let dotProduct 0; let normA 0; let normB 0; union.forEach(tag { const a tagsA.includes(tag) ? 1 : 0; const b tagsB.includes(tag) ? 1 : 0; dotProduct a * b; normA a * a; normB b * b; }); if (normA 0 || normB 0) return 0; return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); }有了相似度推荐过程就三步取用户行为过的景点 → 计算所有未交互景点与它们的相似度 → 按相似度加权排序取Top N。要加权重也行用户评分5星的景点它的相似景点排名应该更靠前所以最后排序得分可以乘上用户对该景点的行为权重。3.4 冷启动问题的处理方案冷启动分成两种用户维度和物品维度。用户冷启动新用户没行为直接推热门榜。热门榜的排序规则我用加权公式热门分 0.6 × 浏览数/最大浏览数 0.4 × 评分/最高评分两层指标归一化到0-1区间再加权避免一个景点因为浏览多但评分低而霸榜。物品冷启动新景点没人浏览没评分推荐系统默认分数偏低容易被埋没。我的处理是给新景点一个“新鲜度加成”在排序阶段加入时间衰减因子——发布7天内的景点热度分上浮15%。虽然简单但能让新内容获得曝光机会不至于永远沉底。4. 后端API与数据存储实现4.1 数据库表设计数据库我用MySQL轻量够用。三张核心表就够了CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, preferences VARCHAR(255) DEFAULT , created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE spots ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, city VARCHAR(50), category VARCHAR(50), tags VARCHAR(255), description TEXT, image_url VARCHAR(255), avg_score DECIMAL(2,1) DEFAULT 0, visit_count INT DEFAULT 0 ); CREATE TABLE behaviors ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, spot_id INT NOT NULL, behavior_type ENUM(view,collect,rating) NOT NULL, rating TINYINT DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_time (user_id, created_at) );设计要点behaviors表建立(user_id, created_at)联合索引因为推荐逻辑最频繁的查询是“某个用户最近的行为记录”没有索引的话数据量上来后查询效率断崖式下降。景点表的tags字段很重要的推荐计算的输入就是它。4.2 Express路由与接口定义后端路由我按资源来划分每个功能一个文件结构清晰// routes/spots.js const express require(express); const router express.Router(); const pool require(../models/db); // 景点列表支持城市、分类筛选 router.get(/, async (req, res) { const { city, category, keyword } req.query; let sql SELECT * FROM spots WHERE 11; const params []; if (city) { sql AND city ?; params.push(city); } if (category) { sql AND category ?; params.push(category); } if (keyword) { sql AND name LIKE ?; params.push(%${keyword}%); } const [rows] await pool.query(sql, params); res.json(rows); }); // 景点详情 router.get(/:id, async (req, res) { const { id } req.params; const [rows] await pool.query(SELECT * FROM spots WHERE id ?, [id]); if (rows.length 0) { return res.status(404).json({ message: 景点不存在 }); } res.json(rows[0]); }); module.exports router;连接池的写法值得注意。每次请求都新建连接太浪费直接用一个mysql2/promise连接池// models/db.js const mysql require(mysql2/promise); const pool mysql.createPool({ host: localhost, user: root, password: 123456, database: travel_db, waitForConnections: true, connectionLimit: 10 }); module.exports pool;connectionLimit: 10的意思是连接池最多保持10个连接高并发场景这个值要调大但本地开发调试10个足够池子太大会浪费MySQL的连接数。4.3 推荐接口完整实现这是整个项目的核心接口我放在routes/recommend.js。逻辑分三步走查行为、算偏好、生成推荐。const express require(express); const router express.Router(); const pool require(../models/db); const WEIGHT_MAP { view: 1, collect: 3, rating: 5 }; async function hotSpots(limit 10) { const [rows] await pool.query( SELECT id, name, city, tags, visit_count, avg_score, (0.6 * visit_count / (SELECT MAX(visit_count) FROM spots) 0.4 * avg_score / 5) AS hot_score FROM spots ORDER BY hot_score DESC LIMIT ? , [limit]); return rows; } // 基于内容推荐 router.get(/byContent/:userId, async (req, res) { const { userId } req.params; const [behaviors] await pool.query( SELECT spot_id, behavior_type FROM behaviors WHERE user_id ? AND created_at DATE_SUB(NOW(), INTERVAL 30 DAY), [userId] ); if (behaviors.length 0) { return res.json(await hotSpots()); } const spotIds behaviors.map(b b.spot_id); const [spots] await pool.query(SELECT id, tags FROM spots WHERE id IN (?), [spotIds]); const spotTags {}; spots.forEach(s spotTags[s.id] s.tags.split(,)); const pref {}; behaviors.forEach(b { const tags spotTags[b.spot_id] || []; const weight WEIGHT_MAP[b.behavior_type] || 1; tags.forEach(tag pref[tag] (pref[tag] || 0) weight); }); const interacted new Set(behaviors.map(b b.spot_id)); const [allSpots] await pool.query(SELECT * FROM spots); const scored allSpots .filter(s !interacted.has(s.id)) .map(spot { let score 0; spot.tags.split(,).forEach(tag { score pref[tag] || 0; }); return { ...spot, score }; }); scored.sort((a, b) b.score - a.score); res.json(scored.slice(0, 10).map(({ score, ...spot }) spot)); }); module.exports router;这里有个细节时间窗口过滤INTERVAL 30 DAY。用户三个月前喜欢滑雪现在夏天还推荐滑雪就离谱了行为数据越新越有参考价值加上时间窗口能让推荐结果跟着季节变。主入口把路由挂上去app.use(/api/spots, require(./routes/spots)); app.use(/api/recommend, require(./routes/recommend)); app.use(/api/behaviors, require(./routes/behaviors));5. 前端Vue页面与交互实现5.1 景点列表与推荐展示页首页的核心功能是景点展示和筛选。接口返回什么页面就渲染什么但要注意数据加载状态的管理。我习惯用一个简单的loading标志script setup import { ref, onMounted } from vue; import { getSpots, recommendByContent } from ../api; import { useUserStore } from ../stores/user; const spots ref([]); const loading ref(true); const activeCity ref(全部); async function loadSpots() { loading.value true; try { spots.value await getSpots({ city: activeCity.value }); } finally { loading.value false; } } async function loadRecommend() { const userStore useUserStore(); if (!userStore.loggedIn) { spots.value await getSpots({ sort: hot }); return; } spots.value await recommendByContent(userStore.id); } onMounted(loadSpots); /script模板上用v-for渲染卡片列表每张卡片点击跳详情页。卡片上要展示名字、城市、标签、评分。图片加载失败时给一个默认占位图这个细节不处理的话线上会显示一堆裂图图标很掉档次。5.2 Vue Router与页面跳转路由配置虽然简单但也有些容易出错的地方。如果用了createWebHistory模式部署到服务器上刷新会404因为服务器上没有对应的物理路径。本地开发没感觉一部署就露馅。解决方式是后端配fallbackExpress里加一句app.get(*, (req, res) { res.sendFile(path.join(__dirname, ../dist/index.html)); });路由跳转用router.push或者router-link。景点详情页接收参数的方式script setup import { useRoute } from vue-router; const route useRoute(); const spotId route.params.id; /script这个spotId传给API接口拉取详情数据。记得在watch里监听route.params.id的变化因为从详情页A跳详情页B时组件实例是复用的不监听的话页面数据不会刷新。5.3 Axios请求封装与跨域处理前端所有请求统一经过axios实例好处是拦截器统一处理token和错误提示// api/index.js import axios from axios; const request axios.create({ baseURL: /api, timeout: 10000 }); request.interceptors.response.use( res res.data, err { console.error(接口请求失败, err.message); return Promise.reject(err); } ); export const getSpots params request.get(/spots, { params }); export const getSpotDetail id request.get(/spots/${id}); export const recommendByContent userId request.get(/recommend/byContent/${userId}); export const addBehavior data request.post(/behaviors, data);跨域在开发环境用Vite代理解决前面提到过// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } });有了代理前端代码里写/api/spotsVite会把请求转发到http://localhost:3000/api/spots。生产环境部署时Nginx里也要做同样的转发这就是为什么baseURL写成/api而不是完整的http://localhost:3000/api——换环境不用改代码。6. 常见问题与排查实录6.1 npm.ps1禁止运行脚本的完整排查这个问题的排查思路我建议理解透因为它是环境问题里最高频的。前面讲了通过PowerShell执行策略去改但还有一次我改了策略依然报错——原因很隐蔽VS Code的终端没有重新加载。执行策略修改后已打开的PowerShell会话不会自动生效必须新开一个终端窗口。如果你改了策略还在报错先别怀疑官方文档关掉终端重开再说。另外有人图省事直接删除npm.ps1文件绕过报错这是馊主意。npm.ps1是官方脚本删了npm命令虽然还能跑实际走的是npm.cmd但一些依赖脚本的高级功能会异常。我在一个项目里遇到过npm run dev能跑但npm run build诡异的失败最后查下来就是被人删过npm.ps1重装了Node才恢复。6.2 端口被占用EADDRINUSEExpress启动报EADDRINUSE说明3000端口被占用了。排查命令netstat -ano | findstr :3000 taskkill /PID 占用进程的PID /F但治标要治本为什么端口会被占最常见的是之前启动的后端进程没有关干净CtrlC没杀掉子进程又开了一个新后端。我的习惯是启动命令前先查一下端口写一个简单的批量处理先kill旧进程再启动省得每次手工查PID。6.3 中文乱码与编码问题Vue前端请求Express接口返回的中文有时候会变成乱码。常见的两个原因一是MySQL连接配置没指定utf8mb4。MySQL 8默认是utf8mb4但如果是老库表结构用的latin1那查出来就是乱码。连接串里要显式指定charsetutf8mb4const pool mysql.createPool({ // ... charset: utf8mb4 });二是接口响应头缺失charset。虽然是JSON响应但Express没有明确告诉浏览器编码格式某些浏览器会猜测成GBK。加一行统一的中间件app.use((req, res, next) { res.setHeader(Content-Type, application/json; charsetutf-8); next(); });这招在开发时经常被人忽略线上踩坑概率不低。6.4 推荐结果太少的排查系统上线跑了一周运营反馈说“推荐来推荐去就是那10个景点”。这个问题的根源多半在标签体系太粗糙。如果所有景点都挂了“热门”“经典”这类标签相似度假如100%协同过滤算出来全是相似景点没有多样性。排查步骤先从数据库统计标签分布SELECT tags, COUNT(*) FROM spots GROUP BY tags;如果发现某些标签覆盖了80%景点说明标签体系需要拆细。我从实践中总结的经验每个景点打3-5个标签其中至少要有一个是“区分性”标签比如“适合徒步”“夜景”“避开人群”这样推荐结果才有差异化和惊喜感。6.5 前端页面白屏的排查思路白屏是最难定位的问题之一因为原因太多。先确定是哪个环节挂了打开浏览器控制台看Network面板API有没有返回返回了看Console有没有报错报错是404说明路由问题500说明后端异常。一个我印象很深的案例项目跑得好好的某天早上来一看首页全白。控制台什么都没报。最后发现是Vite的开发服务器接口代理挂了代理的目标从3000端口变成了某个不存在的端口。排查这类问题有个笨办法但很有效直接把代理的目标地址从http://localhost:3000改成http://127.0.0.1:3000试试。某些Windows环境下Node的DNS解析对localhost返回了IPv6地址::1而Express监听的是IPv4代理就转发失败。这种网络层的小毛病不动代码完全想不到。7. 这套系统后续还能怎么扩展项目做完之后我觉得最值得扩展的方向有三个。第一把协同过滤做得更完整。当前实现偏基于内容等用户行为数据积累上万条之后可以加上基于用户的协同过滤两种策略做加权融合。简单公式最终得分 0.6 × 内容得分 0.4 × 协同得分权重可以按日活调整。这个升级不需要改前端后端加一个策略层就行。第二引入地理位置因素。旅游是典型的位置敏感场景。用户最近浏览的景点在哪个城市推荐结果里就优先推同城或者邻近城市的景点。实现方式也不难景点表加一个region字段推荐排序时做一次分组加权。第三把推荐解释出来。推荐系统被用户信任的关键在于可解释性。Vue前端推荐卡片上可以加一句推荐理由因为你浏览了“西湖”所以推荐“千岛湖”。这需要后端在推荐结果里附带reason字段前端渲染时展示出来。数据上多存一行体验上高一个台阶。我在实际开发中的体会是推荐系统这个项目难点不在算法多高深而在数据链路完整、反馈闭环顺畅。一个能跑通、能解释、能持续优化的简化版系统比一个调了一堆库却不知道原理的“豪华版”有价值得多。如果你正在做类似的项目先把行为采集做好把标签体系设计清楚这比纠结怎么调协同过滤参数重要一百倍。这套代码量不大但整条链路走一遍对全栈开发的认知会上一个台阶。