前阵子帮人搭了一套基于 Node.js + Vue + Express 的旅游景点推荐系统,从环境配置、数据库设计、推荐算法到前后端联调,完整走了一遍。这类项目在个人作品集、毕设和中小型团队内部工具里出镜率非常高,技术栈看着简单,但真要跑通一个“能推荐、有逻辑、不卡死”的系统,坑还是不少。这篇文章就把整套思路、关键代码和踩过的坑全部记录下来,给准备用这套技术栈做类似系统的朋友一份能直接参考的实操笔记。
1. 项目概述与技术选型思考
1.1 为什么是 Node.js + Vue + Express 这套组合
选这套技术栈,核心原因是“成本”和“效率”的平衡。Node.js 让前后端共用 JavaScript 语言,一个开发者可以独立扛起整条链路,不需要在两套语言之间反复切换上下文。Vue 的生态在个人项目和中小型团队里非常友好,组件化、响应式、指令系统这些设计对开发效率的提升是实打实的。Express 则足够轻量,中间件机制简单清晰,写几个 RESTful 接口完全够用,不像一些重量级框架那样带着大量用不上的约束。
有人可能会问,为什么不选 Spring Boot + Vue?如果团队里有 Java 背景的成员、或者系统要大规模处理高并发,那 Spring Boot 确实更合适。但单纯做一个旅游景点推荐系统,用户量和数据规模在可控范围内,Node.js 的异步 I/O 模型处理这类读多写少的业务场景并不吃亏,再加上开发速度上的优势,这套组合更划算。
还有一点很实际:招聘市场上 Vue 工程师和 Node.js 开发者的供给量很大,后续想找人维护、扩展,都比小众技术栈容易得多。这也是很多项目选择这套组合的隐性考量。
1.2 旅游景点推荐系统到底要解决什么问题
旅游景点推荐这个业务场景,本质上是在解决“信息过载”的问题。用户面对海量景点不知道选哪个,系统通过用户的行为数据和景点本身的属性特征,把最可能感兴趣的景点推到前面。
从功能层面拆解,这套系统需要覆盖以下核心模块:
- 用户模块:注册、登录、个人信息维护,登录鉴权用 JWT 实现,确保用户身份可信。
- 景点模块:景点列表展示、关键词搜索、按城市/分类/评分筛选、景点详情查看。
- 行为模块:用户可以对景点进行评分(1-5 分)、收藏、评论,这些行为数据是推荐算法的“燃料”。
- 推荐模块:根据用户的历史行为,推荐可能感兴趣的景点,并给出推荐理由。
数据层面要区分两类反馈:显式反馈(用户主动评分、收藏、评论)和隐式反馈(浏览记录、搜索关键词、停留时长)。显式反馈信号强但数据稀疏,隐式反馈数据量大但噪声多。实际做推荐逻辑时,需要把两者结合起来。
推荐效果的评估指标也值得一提:准确率(推荐的东西用户是否真的喜欢)、覆盖率(推荐结果能不能覆盖多样化的景点,而不是永远推那几热门个)、多样性(同一屏推荐结果差异化程度)、解释性(用户能理解为什么推荐这个景点)。前三个指标衡量推荐质量,第四个直接影响用户对推荐结果的信任度——所以我在系统里特意加了“推荐理由”的展示逻辑,后面会详细说。
2. 系统架构与数据模型设计
2.1 前后端分离的项目结构规划
项目采用前后端分离架构,前端 Vue 工程负责页面渲染和用户交互,后端 Express 工程负责业务逻辑和 API 接口,数据库用 MySQL。两者通过 HTTP/JSON 通信,开发阶段利用代理解决跨域,生产环境可以用 Nginx 统一托管前端静态资源并反向代理后端接口。
目录结构如下:
travel-recommend-system/ ├── frontend/ # Vue 前端工程 │ ├── src/ │ │ ├── api/ # 接口请求封装 │ │ ├── components/ # 公共组件 │ │ ├── router/ # 路由配置 │ │ ├── stores/ # Pinia 状态管理 │ │ ├── views/ # 页面组件 │ │ ├── App.vue │ │ └── main.js │ ├── package.json │ └── vite.config.js ├── backend/ # Express 后端工程 │ ├── config/ # 数据库等配置 │ ├── controllers/ # 控制器(业务逻辑) │ ├── middleware/ # 中间件(鉴权、错误处理) │ ├── models/ # Sequelize 模型定义 │ ├── routes/ # 路由定义 │ ├── utils/ # 工具函数 │ ├── app.js # 入口文件 │ └── package.json └── docs/前后端分离的好处很直观:前端专注于交互和展示,后端专注于数据和业务逻辑,两边可以独立开发、独立部署、独立扩容。开发联调阶段用代理转发接口,生产环境把前端构建产物丢到 Nginx 里,后端用 PM2 守护进程运行,整体部署成本很低。
2.2 数据库表结构与关键设计决策
数据库设计是这套系统的地基,表结构设计得好不好,直接影响推荐算法的实现难度。核心表包括:用户表、景点表、景点标签表、评分表、收藏表。
-- 用户表 CREATE TABLE users ( id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, nickname VARCHAR(50), avatar VARCHAR(255), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 景点表 CREATE TABLE scenic_spots ( id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, description TEXT, location VARCHAR(255), city VARCHAR(50), category VARCHAR(50), cover_url VARCHAR(255), avg_score DECIMAL(3,2) DEFAULT 0.00, view_count INT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 景点标签表 CREATE TABLE spot_tags ( id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, spot_id INT UNSIGNED NOT NULL, tag_name VARCHAR(30) NOT NULL, KEY idx_spot (spot_id), KEY idx_tag (tag_name) ); -- 评分表 CREATE TABLE ratings ( id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, spot_id INT UNSIGNED NOT NULL, score TINYINT NOT NULL, comment VARCHAR(500), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_spot (user_id, spot_id) ); -- 收藏表 CREATE TABLE favorites ( id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, spot_id INT UNSIGNED NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_spot (user_id, spot_id) );几个关键设计决策说明一下。
第一,标签表用单独的表而非景点表里一个 JSON 字段。景点和标签是多对多关系,拆出来一张 spot_tags 表,查询某个景点的标签、按标签反查景点、统计标签权重都很方便。如果塞在 JSON 字段里,写起来痛快,但查询和统计会非常痛苦。
第二,评分表加了 UNIQUE KEY uk_user_spot (user_id, spot_id),保证一个用户对同一景点只能有一条评分记录。这个约束在代码层面也要处理:用户重复评分时,应该走更新逻辑而不是插入新记录。
第三,景点表里的 avg_score 冗余字段。严格来说,平均分可以通过 ratings 表实时 AVG 聚合算出来,但每次列表查询都要做聚合会比较慢,所以把平均分冗余到景点表,每次用户评分后同步更新。这种“以空间换时间”的冗余设计,在读多写少的业务场景下效果很好。
第四,密码字段存的是 password_hash 而不是明文密码,使用 bcrypt 加密。哪怕数据库泄露,用户的密码也不会直接暴露。
3. 后端核心实现:Express 接口与推荐逻辑
3.1 工程初始化与环境配置
后端环境选择 Node.js 18 LTS 版本,自带原生 fetch 和稳定的 ESM 支持。初始化项目并安装依赖:
mkdir backend && cd backend npm init -y npm install express mysql2 sequelize cors jsonwebtoken bcryptjs npm install nodemon --save-dev这里有几个环境细节要注意。Node.js 官网下载 LTS 版本安装后,在终端执行 node -v 和 npm -v 确认安装成功。如果之前装过旧版本,升级后建议清一下 npm 缓存(npm cache clean --force),避免一些莫名其妙的依赖问题。
npm 默认源在部分网络环境下比较慢,可以把源切到国内镜像:
npm config set registry https://registry.npmmirror.com入口文件 app.js 的核心逻辑:
const express = require('express'); const cors = require('cors'); const routes = require('./routes'); const { sequelize } = require('./models'); const app = express(); app.use(cors()); app.use(express.json()); // 路由挂载 app.use('/api', routes); // 统一错误处理 app.use((err, req, res, next) => { console.error(err.stack); res.status(err.status || 500).json({ code: err.status || 500, message: err.message || '服务器内部错误', }); }); const PORT = process.env.PORT || 3000; sequelize.authenticate() .then(() => { console.log('数据库连接成功'); app.listen(PORT, () => console.log(`服务已启动: http://localhost:${PORT}`)); }) .catch((err) => { console.error('数据库连接失败:', err.message); process.exit(1); });我习惯把路由层、控制层、模型层分开,而不是把所有接口逻辑都堆在 app.js 里。刚开始可能觉得文件多,但项目一旦开始加功能,这种分层的好处会非常明显——每个文件职责单一,改起来不会牵扯到无关代码。
数据库连接配置用 Sequelize:
const { Sequelize } = require('sequelize'); const sequelize = new Sequelize('travel_recommend', 'root', 'your-password', { host: 'localhost', dialect: 'mysql', dialectOptions: { charset: 'utf8mb4' }, timezone: '+08:00', }); module.exports = sequelize;注意 timezone: '+08:00' 这个配置,如果不设置,Sequelize 读出来的时间会比北京时间少 8 小时,非常坑。
3.2 用户鉴权与景点接口实战
用户注册和登录使用 JWT 做无状态鉴权。注册接口对密码做 bcrypt 哈希,登录接口校验密码后签发 token。
const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); // 注册 async function register(req, res) { const { username, password, nickname } = req.body; if (!username || !password) { return res.status(400).json({ code: 400, message: '用户名和密码不能为空' }); } const exists = await User.findOne({ where: { username } }); if (exists) { return res.status(400).json({ code: 400, message: '用户名已存在' }); } const passwordHash = await bcrypt.hash(password, 10); const user = await User.create({ username, password_hash: passwordHash, nickname }); res.json({ code: 0, message: '注册成功', data: { id: user.id, username: user.username } }); } // 登录 async function login(req, res) { const { username, password } = req.body; const user = await User.findOne({ where: { username } }); if (!user || !(await bcrypt.compare(password, user.password_hash))) { return res.status(401).json({ code: 401, message: '用户名或密码错误' }); } const token = jwt.sign({ id: user.id, username: user.username }, process.env.JWT_SECRET || 'dev-secret', { expiresIn: '7d', }); res.json({ code: 0, message: '登录成功', data: { token, user: { id: user.id, username: user.username, nickname: user.nickname } } }); }鉴权中间件在 protected 接口上统一校验 Authorization 头里的 Bearer token:
function auth(req, res, next) { const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : null; if (!token) { return res.status(401).json({ code: 401, message: '未登录' }); } try { req.user = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret'); next(); } catch (e) { return res.status(401).json({ code: 401, message: '登录已过期,请重新登录' }); } }景点列表接口是前端最常调用的接口,支持分页、关键词搜索、城市筛选、分类筛选、按评分或浏览量排序。用 Sequelize 构建动态查询条件时,要注意避免 SQL 注入风险,尽量使用参数化查询或 Sequelize 的条件对象。
async function listSpots(req, res) { const { page = 1, pageSize = 10, keyword = '', city = '', category = '', sortBy = 'avg_score' } = req.query; const where = {}; if (keyword) { where.name = { [Op.like]: `%${keyword}%` }; } if (city) where.city = city; if (category) where.category = category; const order = sortBy === 'view_count' ? [['view_count', 'DESC']] : [['avg_score', 'DESC']]; const { rows, count } = await ScenicSpot.findAndCountAll({ where, order, limit: Number(pageSize), offset: (Number(page) - 1) * Number(pageSize), }); res.json({ code: 0, data: { list: rows, total: count, page: Number(page), pageSize: Number(pageSize) } }); }这里有个小细节:findAndCountAll 同时返回当前页数据和总数,前端分页组件就能直接拿到 total 展示总页数。排序字段没有直接拼进 order 对象而是做了白名单映射,防止用户传任意字段进去导致报错。
3.3 推荐算法从冷启动到协同过滤
推荐逻辑是这套系统的灵魂。我的实现分三层递进,目标是让推荐结果在不同阶段都可用、可解释。
第一层,冷启动推荐。新用户没有任何行为数据,系统无法判断用户的偏好,这时推荐策略就是“热门景点榜”,按浏览量、平均分、收藏量综合排序。这个方案简单、安全,用户在什么都不了解的情况下,大概率也愿意看看大家都在去的景点。
第二层,基于内容的标签匹配推荐。用户有了一些行为数据后,可以分析用户偏好的标签。比如用户收藏了“西湖”和“灵隐寺”,这两个景点都有“历史文化”标签,那就提高“历史文化”类景点的权重,推荐同标签下评分高的其他景点。
实现思路是在用户登录并获得授权后,查询该用户近期评分和收藏过的景点,聚合这些景点的标签,得到用户的标签偏好向量。然后对每个候选景点,计算其标签与用户偏好标签的匹配度,按匹配度降序返回结果。
async function getTagPreference(userId) { // 1. 查用户评分过的景点ID const ratings = await Rating.findAll({ where: { user_id: userId }, attributes: ['spot_id'] }); // 2. 查用户收藏的景点ID const favorites = await Favorite.findAll({ where: { user_id: userId }, attributes: ['spot_id'] }); const spotIds = [...new Set([...ratings.map((r) => r.spot_id), ...favorites.map((f) => f.spot_id)])]; if (spotIds.length === 0) return {}; // 3. 聚合这些景点的标签 const tags = await SpotTag.findAll({ where: { spot_id: spotIds } }); const pref = {}; tags.forEach((t) => { pref[t.tag_name] = (pref[t.tag_name] || 0) + 1; }); return pref; }第三层,基于用户的协同过滤(User-Based Collaborative Filtering)。核心思想是:如果用户 A 和用户 B 对多个景点的评分相似,那么 A 喜欢的而 B 没看过的景点,很可能 B 也会喜欢。
协同过滤的关键是计算用户之间的相似度,我用的是皮尔逊相关系数。公式上,对两个用户共同评分过的景点集合,计算评分序列的相关系数,值域在 -1 到 1 之间,越接近 1 表示两个用户口味越一致。
// 构建用户评分映射: { userId: { spotId: score } } function buildUserRatingMap(ratings) { const map = {}; ratings.forEach((r) => { if (!map[r.user_id]) map[r.user_id] = {}; map[r.user_id][r.spot_id] = r.score; }); return map; } // 皮尔逊相关系数 function pearson(userA, userB) { const common = Object.keys(userA).filter((id) => userB[id] !== undefined); if (common.length < 2) return 0; const n = common.length; const sumA = common.reduce((s, id) => s + userA[id], 0); const sumB = common.reduce((s, id) => s + userB[id], 0); const sumASq = common.reduce((s, id) => s + userA[id] * userA[id], 0); const sumBSq = common.reduce((s, id) => s + userB[id] * userB[id], 0); const sumAB = common.reduce((s, id) => s + userA[id] * userB[id], 0); const numerator = sumAB - (sumA * sumB) / n; const denominator = Math.sqrt((sumASq - sumA * sumA / n) * (sumBSq - sumB * sumB / n)); return denominator === 0 ? 0 : numerator / denominator; }计算完相似度后,找到与当前用户最相似的 K 个用户(通常取 10-20 个),用这些用户对目标景点的评分做加权平均,权值就是相似度。预测分公式:
function predictScore(targetUser, otherUsers, targetSpotId) { let totalWeight = 0; let totalScore = 0; for (const userId of otherUsers) { const score = otherUsers[userId][targetSpotId]; if (!score) continue; const sim = pearson(targetUser, otherUsers[userId]); if (sim <= 0) continue; // 负相关的用户反而会带来噪音,直接过滤 totalWeight += sim; totalScore += sim * score; } return totalWeight === 0 ? null : totalScore / totalWeight; }实际落地时,我的混合策略是:优先用协同过滤的结果,如果协同过滤能算出的候选集太小(比如用户太新、共同评分对象太少),就回退到标签匹配;标签匹配也没数据,就回退到热门榜。这样三层兜底,任何阶段都有推荐结果可看。
还有一个用户体验的加分项——推荐理由。系统返回推荐结果时,同时返回推荐原因,比如:“因为你评分过西湖和灵隐寺,它们都属于历史文化类标签,所以为你推荐故宫。”推荐理由能显著提升用户对推荐结果的信任感,这是很多推荐系统忽略的点。
性能这块要提醒一下:皮尔逊相关系数的实时计算,在用户量和评分数据多了之后会越来越慢。数据量比较小的时候,内存计算没问题;数据量大了,要么用 Redis 缓存计算结果,要么离线跑批预先算好用户相似度矩阵。这套系统里我用了简单的内存缓存,设置 5 分钟过期时间,足以应付中小规模场景。
4. 前端核心实现:Vue 页面与交互
4.1 Vue 工程搭建与路由设计
前端使用 Vue 3 + Vite + Element Plus + Pinia 的组合。Vite 的冷启动速度快,开发体验比老一代 Webpack 好太多。
npm create vite@latest frontend -- --template vue cd frontend npm install npm install vue-router@4 pinia element-plus axios路由设计决定了页面结构。这套系统的路由分为游客可访问和登录后访问两类:
import { createRouter, createWebHistory } from 'vue-router'; const routes = [ { path: '/', redirect: '/home' }, { path: '/home', name: 'Home', component: () => import('@/views/Home.vue') }, { path: '/spots', name: 'Spots', component: () => import('@/views/SpotList.vue') }, { path: '/spot/:id', name: 'SpotDetail', component: () => import('@/views/SpotDetail.vue') }, { path: '/recommend', name: 'Recommend', component: () => import('@/views/Recommend.vue'), meta: { requiresAuth: true } }, { path: '/login', name: 'Login', component: () => import('@/views/Login.vue') }, { path: '/profile', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { requiresAuth: true } }, ]; const router = createRouter({ history: createWebHistory(), routes, }); // 全局前置守卫 router.beforeEach((to) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { return { name: 'Login', query: { redirect: to.fullPath } }; } return true; });路由守卫是控制页面访问权限的常用手段。这里把 requireAuth 标记写在路由 meta 里,比在每个页面组件里手动判断要干净得多。登录后可以跳回用户原本想访问的页面,这个体验细节要保留。
4.2 Axios 请求封装与推荐页实现
Axios 封装是前端工程里非常关键的一层。统一处理 baseURL、超时时间、请求头携带 token、响应拦截错误提示,避免每个页面重复写这些逻辑。
import axios from 'axios'; import { ElMessage } from 'element-plus'; import router from '@/router'; const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000, }); // 请求拦截器:自动携带 token request.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); // 响应拦截器:统一处理错误 request.interceptors.response.use( (response) => { const res = response.data; if (res.code !== 0) { ElMessage.error(res.message || '请求失败'); return Promise.reject(new Error(res.message || '请求失败')); } return res; }, (error) => { if (error.response?.status === 401) { localStorage.removeItem('token'); router.push({ name: 'Login' }); } ElMessage.error(error.response?.data?.message || '网络异常,请稍后重试'); return Promise.reject(error); } ); export default request;推荐页是这套系统的核心页面,展示三类信息:推荐景点列表、推荐理由、刷新推荐按钮。每次进入推荐页调用后端推荐接口,拿到的是混合策略算出来的结果。
<script setup> import { ref, onMounted } from 'vue'; import { getRecommendList } from '@/api/recommend'; const list = ref([]); const loading = ref(false); async function loadRecommend() { loading.value = true; try { const res = await getRecommendList(); list.value = res.data; } finally { loading.value = false; } } onMounted(() => { loadRecommend(); }); </script>景点详情页除了展示基本信息,还集成了评分和收藏功能。评分后要调接口更新景点平均分,收藏按钮的状态要实时反馈给用户。评分组件用 Element Plus 的 el-rate,收藏状态存在 Pinia 里,用户刷新页面后还能保持一致。
4.3 状态管理与接口对接细节
Pinia 在 Vue 3 项目里是官方推荐的状态管理方案。这套系统里主要存储用户信息和登录状态:
import { defineStore } from 'pinia'; export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: JSON.parse(localStorage.getItem('userInfo') || 'null'), }), getters: { isLoggedIn: (state) => !!state.token, }, actions: { setLogin(token, userInfo) { this.token = token; this.userInfo = userInfo; localStorage.setItem('token', token); localStorage.setItem('userInfo', JSON.stringify(userInfo)); }, logout() { this.token = ''; this.userInfo = null; localStorage.removeItem('token'); localStorage.removeItem('userInfo'); }, }, });token 和用户信息需要持久化到 localStorage,否则刷新页面就丢登录态了。这里没有用 vuex-persistedstate 这类库,是因为手动存 localStorage 就两三行代码,没必要引额外依赖。
前后端联调时,最常见的坑就是接口地址对不上。我在前端项目根目录建了 .env.development 和 .env.production 两个文件,分别配置 VITE_API_BASE_URL。开发环境为空字符串,走 Vite 代理;生产环境配实际的后端域名。这样就不用在代码里写死接口地址,环境切换也不会出错。
5. 常见问题与排查技巧实录
5.1 npm.ps1 脚本执行权限报错
这个问题几乎每个用 Windows 做 Node.js 开发的人都会遇到。在 PowerShell 里执行 npm install 或 npm run dev 时,报错:
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。原因是 PowerShell 默认执行策略是 Restricted,不允许运行 .ps1 脚本文件。npm 命令本质上是调用 npm.ps1 脚本,所以被拦住了。
解决办法是在 PowerShell 里修改执行策略:
Set-ExecutionPolicy -Scope CurrentUser RemoteSignedRemoteSigned 表示本地创建的脚本可以运行,从网上下载的脚本需要数字签名。这个设置只对当前用户生效,不会影响系统级策略,安全性是有保障的。改完重新打开终端,问题就解决了。
顺带提一个相关场景:如果你用的是其他终端(比如 CMD 或 Git Bash),通常不会遇到这个问题,所以遇到权限报错不要慌,先看清楚当前是哪个终端。
5.2 跨域问题与联调代理
前端跑在 5173 端口,后端跑在 3000 端口,直接从前端页面发起请求会触发跨域限制。开发阶段有两种解决办法。
第一种是后端加 CORS 中间件:
const cors = require('cors'); app.use(cors());这样后端响应头会自动加上 Access-Control-Allow-Origin,浏览器跨域限制就通过了。生产环境记得做白名单限制,只允许自己的前端域名访问,否则任何人都能调用你的接口。
第二种是前端 Vite 代理。在 vite.config.js 里配置:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, }, }, }这样前端请求 /api/xxx,开发服务器会转发到 http://localhost:3000/api/xxx。好处是前端代码里不需要写完整的后端地址,统一走 /api 前缀。
我实际开发时两种都配了:后端开 CORS 方便用 Postman 调试,前端配代理方便页面直接联调。生产环境 Nginx 统一托管静态资源并反向代理 /api 到后端服务,前端不直接暴露接口地址。
5.3 Vue Router history 模式刷新 404
项目打包部署到 Nginx 后,在首页点击跳转正常,但直接访问某个二级路由页面(比如 /recommend)或者刷新页面,就会出现 404。
原因在于 Vite 构建产物是纯静态文件,Nginx 默认找不到 /recommend 这个路径对应的物理文件。
解决办法有两个。第一个是 Nginx 里加 try_files 配置,把所有路由请求都回退到 index.html:
location / { try_files $uri $uri/ /index.html; }这样前端路由在跳转时不会向服务器请求真实路径,而是在 HTML5 History API 的层面进行路由切换,刷新页面时 Nginx 把请求重写到 index.html,由 Vue Router 自己解析当前路径。
第二个办法是改成 hash 模式。把 createWebHistory 换成 createWebHashHistory,路由路径变成 /#/recommend 这种形式,任何路径下刷新都不会 404。缺点是不如 history 模式优雅,URL 里多个 # 号,分享链接时观感略差。
我的建议是:能用 Nginx 配置 try_files 就优先用 history 模式;如果前端托管在纯静态文件服务器上(比如某些一键托管平台),那就老老实实用 hash 模式,省心。
5.4 端口占用与数据库乱码
端口占用是 Express 服务最常见的启动报错。EADDRINUSE 的意思是端口被占用,排查办法是先按端口查占用进程:
netstat -ano | findstr :3000 taskkill /PID <PID> /FmacOS/Linux 上用 lsof -i :3000 和 kill -9 。
数据库中文乱码这个问题,主要出在建库和连接配置上。建库时指定 utf8mb4:
CREATE DATABASE travel_recommend DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;连接时在 Sequelize 配置里加上 charset: 'utf8mb4'。注意 utf8mb4 和 utf8 的区别:utf8mb4 是 MySQL 5.5.3 之后引入的,支持完整的 Unicode 字符集,包括 Emoji 和一些生僻字。如果用了 utf8,稍有特殊的字符就会变成问号。
另外还有一个经常被忽略的问题:npm 安装依赖时版本冲突。Express 5 和 Express 4 的中间件兼容性有差异,如果安装了最新版 Express 5,一些老教程里的写法可能会不兼容。建议装 Express 4 稳定版:
npm install express@4虽然 Express 5 已经发布,但社区里的资料大部分还停留在 4.x,踩坑成本已经很低了,没必要冒险用最新版。
团队里如果用的是 Windows + macOS 混合开发,还要注意路径分隔符差异。Windows 用反斜杠,macOS 用正斜杠,代码里尽量不要硬编码路径,用 path.join 处理。
最后再分享一个小技巧:后端调试尽量用 Postman 或 Apifox 这类接口调试工具,把每个接口的用例保存下来。前端联调时发现问题,可以先用接口工具确认后端是否正常,缩小排查范围。这套系统前前后后开发完,最大的体会就是:推荐系统的效果不是一蹴而就的,需要根据用户的反馈持续调整算法参数和权重。刚开始做出来的推荐结果可能看起来很蠢,但只要打分、收藏、浏览这些行为数据积累起来,推荐质量会肉眼可见地提升。技术栈的选型重要,但更重要的是把每个环节的细节做扎实——数据库设计是否合理、接口是否健壮、推荐逻辑是否有兜底,这些才是系统能否长期跑下去的关键。