ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Node.js+Vue+Express旅游景点推荐系统从零实战指南

Node.js+Vue+Express旅游景点推荐系统从零实战指南 前阵子帮人搭了一套基于 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 里。刚开始可能觉得文件多但项目一旦开始加功能这种分层的好处会非常明显——每个文件职责单一改起来不会牵扯到无关代码。数据库连接配置用 Sequelizeconst { 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 tokenfunction 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 vitelatest frontend -- --template vue cd frontend npm install npm install vue-router4 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.htmllocation / { 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 。数据库中文乱码这个问题主要出在建库和连接配置上。建库时指定 utf8mb4CREATE 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 express4虽然 Express 5 已经发布但社区里的资料大部分还停留在 4.x踩坑成本已经很低了没必要冒险用最新版。团队里如果用的是 Windows macOS 混合开发还要注意路径分隔符差异。Windows 用反斜杠macOS 用正斜杠代码里尽量不要硬编码路径用 path.join 处理。最后再分享一个小技巧后端调试尽量用 Postman 或 Apifox 这类接口调试工具把每个接口的用例保存下来。前端联调时发现问题可以先用接口工具确认后端是否正常缩小排查范围。这套系统前前后后开发完最大的体会就是推荐系统的效果不是一蹴而就的需要根据用户的反馈持续调整算法参数和权重。刚开始做出来的推荐结果可能看起来很蠢但只要打分、收藏、浏览这些行为数据积累起来推荐质量会肉眼可见地提升。技术栈的选型重要但更重要的是把每个环节的细节做扎实——数据库设计是否合理、接口是否健壮、推荐逻辑是否有兜底这些才是系统能否长期跑下去的关键。
返回列表