做美食推荐小程序这个项目,起因倒是很简单:想给自己和身边几个朋友做一个能“越用越懂我”的找饭工具。市面上能看评分、看距离的App不少,但真正能根据你过去点过什么、喜欢什么口味来给出个性化推荐的,体验做得好的其实不多。于是干脆自己动手,用 Node.js + PHP + Vue 再加一个协同过滤算法,把它做成了微信小程序。
先说结论:这套组合干活完全够用。PHP 负责业务接口和算法计算,Node.js 承担定时任务和数据预处理,Vue 搭管理后台,小程序端面向用户。重点不在技术栈多新,而在协同过滤算法怎么在真实业务里落地。项目里的用户行为采集、菜品评分矩阵构建、相似度计算这几块,踩了不少坑,也总结了一些可以少绕弯子的经验。这篇文章就把从环境配置到算法实现、从小程序联调到上线维护的完整过程都梳理一遍,适合正在做毕设、想入行小程序开发、或者自己折腾美食/电商类项目的朋友参考。
1. 项目选型与整体架构设计
1.1 技术栈组合与分工
先说一个很多人问的问题:为什么 PHP 和 Node.js 同时用?一个项目里搞两套后端语言,听着像是没事找事,但实际用下来是分工考虑。PHP 主业务接口、管理员操作、菜品和用户的数据管理,这套东西用 PHP 写起来快,生态也成熟,随便一台服务器就能跑;Node.js 则是处理定时任务和算法预处理,比如每天凌晨拉取前一天的用户行为、构建矩阵、跑推荐结果。Node 对高并发的 I/O 任务处理比 PHP 更自然,写定时脚本配合 cron 也方便。
Vue 在这套项目里负责管理后台。商家和管理员需要维护菜品库、查看用户行为数据、手动干预推荐结果——比如某个新店想冲排名,可以临时加权重。这部分如果用传统模板渲染也能做,但 Vue 的双向绑定和组件化让表格、表单这类频繁交互的操作舒服很多,尤其是菜品列表的批量编辑、推荐分区的拖拽排序,体验远超传统写法。
小程序端是用户的直接入口。推荐列表、菜品详情、评分交互、收藏和点餐记录都在这里完成。小程序的性能虽然比不上原生 App,但胜在开发效率高、分发成本低。微信生态里还有现成的登录体系和支付能力,对个人开发者和中小项目非常友好。
1.2 协同过滤算法在美食场景中的业务映射
协同过滤算法有多版演进版本,但在美食推荐这个场景里,我选了用户协同过滤为主、物品协同过滤为辅的组合策略。基础逻辑不复杂:用户 A 和用户 B 的口味相近,A 喜欢吃的菜,B 大概率也喜欢。
具体落到业务上有三个层面:
- 用户对菜品的评分行为:显式反馈,比如用户打了 5 星;
- 用户的点餐/收藏行为:隐式反馈,点了一份麻辣香锅,说明大概率接受这个口味;
- 用户浏览行为:弱反馈,只看不点可能是犹豫,权重放低。
这三种行为组成一个“用户-菜品”行为矩阵,矩阵里的值加权合并,变成算法输入。输出是每个用户的 TopN 推荐列表。这个列表不是实时算的,而是每天定时批量算好,写入 Redis 或者 MySQL 的推荐结果表,用户打开小程序时直接读取。用户量不大时这样最划算,不用每次请求都跑一遍矩阵计算,后面容量不够再上实时计算也来得及。
这套设计里最关键的一点是:算法不是全部,行为采集才是地基。行为数据如果没采集干净,矩阵再漂亮也是白搭。我在项目里单独做了一个行为日志表,每次曝光、点击、下单、评分都会异步写入,Node.js 那边定时清洗然后落库。
2. 环境搭建与工程初始化
2.1 Node.js 安装和那个烦人的 npm 报错
网上搜这个项目的人,很大概率同时在搜“npm 无法加载文件 npm.ps1”,说明大家卡在了同一个地方。先说安装,Windows 下直接去 Node.js 官网下载 LTS 版本,安装包一路 Next 就行。安装完验证是否成功,在命令行里敲:
node -v npm -v能看到 v18.x.x 之类的版本号就说明装好了。多数人偏偏卡在了 npm 上——敲任何 npm 命令都提示:
npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本这个错的原因是 PowerShell 默认脚本执行策略是 Restricted,不允许运行 .ps1 脚本。解决思路不是绕过安全机制,而是把策略改为针对当前用户有效即可。以管理员身份打开 PowerShell,执行:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser然后选 Y,重新打开终端,npm 命令就正常了。这里不建议直接改成 Unrestricted,RemoteSigned 已经足够覆盖开发需要,也更稳妥。
Node.js 装好后顺便把镜像源换成国内镜像,下载依赖的速度会差出好几个量级。执行:
npm config set registry https://registry.npmmirror.com再执行一下 npm config get registry,确认输出是上面那个地址就可以。
2.2 Vue 管理后台创建与依赖安装
Vue 项目我用 Vite 创建,相比 vue-cli 启动速度快、依赖安装也干净。执行:
npm create vite@latest food-admin -- --template vue cd food-admin npm install核心依赖这么几个:
- vue-router:管理后台的路由跳转;
- pinia:全局状态管理,比 Vuex 更轻;
- axios:请求后端接口;
- element-plus:后台管理的 UI 组件库,表格、表单、弹窗都有现成的。
安装命令合在一起写:
npm install vue-router@4 pinia axios element-plusVue 这边要提醒一个入门常见问题:vue-router 传参有 query 和 params 两种方式,跳详情页的时候用 query 最简单,刷新页面参数还在;params 在新版路由里需要使用动态路由配置,否则刷新就丢参。管理后台的菜品编辑页我用的是动态路由方式,路径类似/dish/edit/:id,详情页可以直接从route.params.id取菜品 ID,体验更干净。
2.3 PHP 环境准备与接口骨架搭建
PHP 我用的是 phpstudy 作为集成环境,版本直接上 PHP 8.1 以上。项目里 PHP 端做了一个简单的 MVC 结构,没有引入完整框架,控制器、模型、服务三层分开。为什么不用 Laravel 或者 ThinkPHP?一方面是小程序接口路径比较固定,另一方面是算法部分我自己写得比较多,框架反而碍事。
接口以 JSON 格式返回,PHP 端统一封装一个响应函数:
function apiResponse($code, $data, $msg = 'ok') { header('Content-Type: application/json;charset=utf-8'); echo json_encode([ 'code' => $code, 'data' => $data, 'msg' => $msg ]); exit; }写接口的第一个坑就是跨域。小程序端请求通常没跨域问题,但 Vue 管理后台开发环境跑在 5173 端口,请求 PHP 接口跑在 80 端口,跨域就来了。PHP 端要提前把响应头加上:
header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization');还有更隐蔽的一个问题,PHP 8 之后数组和对象的转换经常让刚从老教程入门的人摸不着头脑。比如数据库取出来的二维数组,json_encode 之后到了前端可能是对象而不是数组。Vue 里用v-for遍历前最好Array.isArray()确认一下,或者 PHP 端统一用array_values()重置下标,再返回给前端。
3. 协同过滤推荐算法的核心落地
3.1 行为数据采集与矩阵构建
算法部分不是上来就写余弦相似度,第一步是把行为数据变成矩阵。我设计了三张核心表:
user:用户表,小程序登录后自动创建;dish:菜品表,包含分类、口味标签、价格、图片;user_behavior:用户行为表,记录曝光、点击、收藏、评分、下单五类行为。
行为表结构简化如下:
CREATE TABLE `user_behavior` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `dish_id` int(11) NOT NULL, `behavior_type` varchar(20) NOT NULL, -- view / click / favorite / rate / order `rating` tinyint(1) DEFAULT NULL, -- 只有 rate 行为有值 `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user` (`user_id`), KEY `idx_dish` (`dish_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;行为数据要转成矩阵里的分值,不能直接用 0 和 1 平铺,不同行为权重差异很大。我的权重定义如下:
- 下单(order):5 分。这是最强的正向反馈,用户真金白银买了单;
- 评分(rate):直接用评分值乘以 0.8,比如打了 5 星等于 4 分;
- 收藏(favorite):3 分。收藏代表明确偏好;
- 点击(click):1 分。弱反馈,但量大,能补足稀疏矩阵;
- 曝光(view):不计分,只用于后面算 CTR 之类的辅助指标。
Node.js 的定时任务每天凌晨 2 点跑一次数据清洗任务。脚本逻辑是:读取前一天新增的 user_behavior,按用户和菜品分组聚合,算出每个用户对每个菜品的综合得分。写入一张独立的user_dish_scores表,字段就是 user_id、dish_id、score。这样矩阵不是临时在内存里拼的,而是每天固化到数据库里,后续算法直接查表即可,调试也方便。
3.2 用户协同过滤:余弦相似度计算
用户协同的核心是找相似用户。常用算法是余弦相似度,公式是:
similarity = (A向量 · B向量) / (|A向量| * |B向量|)在 PHP 里实现一个简化版本,直接查user_dish_scores表,取两个用户的共同菜品维度算相似度:
public function userSimilarity($userIdA, $userIdB) { // 取出两个用户对所有菜品的评分 $scoresA = $this->getUserScores($userIdA); $scoresB = $this->getUserScores($userIdB); // 寻找两个用户共同评分过的菜品 $common = array_intersect_key($scoresA, $scoresB); if (count($common) === 0) { return 0; } $dot = 0; $normA = 0; $normB = 0; foreach ($scoresA as $dishId => $score) { $normA += pow($score, 2); } foreach ($scoresB as $dishId => $score) { $normB += pow($score, 2); } foreach ($common as $dishId => $score) { $dot += $score * $scoresB[$dishId]; } if ($normA == 0 || $normB == 0) { return 0; } return $dot / (sqrt($normA) * sqrt($normB)); }这里特别注意:算共同菜品时必须用 array_intersect_key,而不是遍历某个用户的所有菜品去判断另一方有没有。为什么?因为用户菜品矩阵非常稀疏,一个用户可能只对几十个菜品有过行为,全部菜品上千个,遍历全库再判断存在性会慢很多,用 PHP 的 hash 查找能快一个量级。
相似用户取多少个?实践中 TopK 取 10~20 比较合理。太少了推荐结果太窄,太多了计算量大,增益却不明显。我的策略是取相似度排名前 15 的用户,再把这些用户评分过的菜品按加权分数聚合,候选菜品过滤掉用户已经买过/评过分的,剩下的按分数排序取前 20 作为推荐候选。
3.3 物品协同过滤:用户冷启动的补充方案
只有用户协同过滤会有个明显问题:新用户没有任何行为数据,矩阵里全是空的,余弦相似度直接算不出来。所以项目里同时实现了物品协同过滤作为冷启动补充。
物品协同的核心逻辑是:如果有很多用户同时喜欢 A 菜品和 B 菜品,那么 A 和 B 之间就存在关联。给一个用户推荐时,根据他最近喜欢的菜,找到关联度最高的其他菜。
这个实现比用户协同简单。PHP 里先统计所有用户的偏好菜品集合,生成“喜欢菜品 A 也喜欢菜品 B”的共现矩阵。每天 Node.js 定时任务会计算各菜品之间的共现次数,存到 dish_similarity 表里。用户端展示时:
- 老用户走用户协同结果为主,新用户没有行为数据时走物品协同;
- 连一个行为都没有的纯新用户,直接返回全局热门菜品 Top20;
- 用户行为很少(比如只有一两单)时,用物品协同比用户协同更靠谱,因为两三个行为不足以找到相似用户。
冷启动的策略优先级实现代码如下:
public function getRecommendList($userId, $limit = 20) { $behaviorCount = $this->countUserBehavior($userId); if ($behaviorCount == 0) { // 完全没有行为,返回热门排行 return $this->getHotDishes($limit); } if ($behaviorCount < 5) { // 行为太少,用物品协同推荐 return $this->getItemBasedRecommend($userId, $limit); } // 行为足够,走用户协同 + 物品协同融合 $userBased = $this->getUserBasedRecommend($userId, $limit * 0.7); $itemBased = $this->getItemBasedRecommend($userId, $limit * 0.3); return $this->mergeAndFilter($userBased, $itemBased, $userId); }这里融合策略有个细节:用户协同结果占 70%,物品协同占 30%,而不是简单的 50% 对 50%。因为用户协同对偏好的捕捉更准确,物品协同更多是补充多样性。按这个比例融合,实测下来推荐的点击率比纯用户协同高不少,因为物晶协同引入了一些用户自己都没想到但确实相关的菜。
3.4 Node.js 定时任务与推荐结果缓存
PHP 负责算法计算逻辑没问题,但用户每次打开小程序都跑一遍计算就不行了。所以真正的生产链路是:Node.js 写定时任务调 PHP 的算法服务,算好的结果存到数据库和 Redis,小程序请求走缓存。
Node.js 这边用 node-cron 做定时调度:
const cron = require('node-cron'); const axios = require('axios'); cron.schedule('0 2 * * *', async () => { console.log('开始执行推荐任务计算...'); try { const res = await axios.post('http://127.0.0.1:80/api/recommend/compute'); if (res.data.code === 0) { console.log('推荐结果更新成功'); } } catch (error) { console.error('推荐任务执行失败:', error.message); } });推荐结果表要设置合理过期时间,避免用户看到的数据太陈旧。我的做法是 generated_date 字段记录生成日期,小程序端请求时如果发现缓存结果超过 3 天,就临时触发一次实时计算并提示“推荐结果正在更新”。这样既能保证日常响应速度,也不会让用户觉得推荐“永远不变”。
缓存这块踩过一个坑:一开始我把推荐结果存在 MySQL 里,每次请求都查一次,结果数据量一上来,接口响应从几十毫秒变成几百毫秒。后来改成 Redis,响应时间基本稳定在 30 毫秒内。Redis 键结构是recommend:user:{userId},值是一串菜品 ID,前端拿到后再根据 ID 批量查菜品信息。
4. 小程序端与 Vue 管理后台的对接实现
4.1 微信小程序的请求封装与推荐页渲染
小程序端不是直接用 wx.request 到处写,而是封装了一个统一的请求模块。封装的核心原因有三个:统一处理 token、统一处理错误码、统一处理 loading 状态。
请求模块核心逻辑:
// utils/request.js const BASE_URL = 'https://api.example.com'; function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method, data, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') }, success(res) { if (res.data.code === 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg, icon: 'none' }); reject(res.data); } }, fail(err) { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); } module.exports = { request };推荐页的渲染我用了 scroll-view 做无限滚动。首次进入加载 20 条推荐,下拉到底部再加载下一批。需要注意的坑是:onReachBottom在页面高度不够时不会触发,所以要在onReady里先判断一次内容高度,不足就自动加载更多。这个小问题折腾了我一晚上。
推荐列表的卡片设计上,除了菜品图、名称、价格以外,还做了一个“为什么推荐给你”的小标签——如果来自相似用户的偏好,就显示“和你口味相似的人都在点”;如果来自物品协同,就显示“你喜欢的XX,很多人也点了这道菜”。这个设计加分很明显,用户对推荐结果的信任感直接提升,后面的点击率数据也验证了这一点。
4.2 用户行为采集与评分交互
推荐算法要持续变准,行为采集必须从小程序端严谨埋点。我在三个位置埋了行为数据:
- 推荐列表曝光:每张卡片露出屏幕视口时上报 view 行为;
- 点击卡片进入详情:上报 click 行为;
- 详情页点“收藏”和“下单”:上报 favorite 和 order 行为。
埋点上报统一走一个/api/behavior/report接口,PHP 端异步写队列,避免高频上报阻塞业务接口。
评分交互这里多说一嘴:很多项目只做“点赞/不点赞”,但显式评分对协同过滤的帮助远超想象。我做了五星评分弹窗,用户下单完成后弹出,愿意评分的用户虽然比例不高,但每一个评分都是高质量训练样本。在矩阵里一个明确打 5 星的用户,价值顶得上几次隐式下单行为。
埋点上报里有个隐蔽的问题:小程序端频繁请求会触发微信的并发限制。批量埋点数据不用一条一条发,前端先在本地攒 10 条或者每隔 30 秒,再一次性批量上报。PHP 端接数组参数循环入库即可。
4.3 Vue 管理后台:菜品管理与推荐干预
管理后台是 Vue + Element Plus。核心功能三块:菜品管理、用户行为看板、推荐干预。
菜品管理就是一个标准 CRUD,表格组件绑定数据,编辑用对话框。其中图片上传使用小程序的临时文件接口转存到服务器,这部分的关键是处理图片压缩,不然用户上传一张手机原图可能好几 MB,直接把服务器打满。PHP 端用了imagecreatefromjpeg重新采样输出,压缩到 800px 宽以内。
用户行为看板展示当天各行为数据量,以及行为趋势折线图。数据接口就是查询 user_behavior 表按天分组,返回给 ECharts 或 Element 的图表组件。这个看板的价值在于:你可以直观地看到埋点是否正常,上线第一天如果曝光量很高但点击率极低,多半是推荐结果和用户预期偏离太远。
推荐干预这个功能是我自己加的。管理后台有个“推荐配置”页,可以对特定菜品设置推荐权重加成。比如新店开业,临时把某道菜的权重乘上 2,让它在推荐列表里排名靠前push。实现上就是在推荐结果合并排序时,权重加成体现在分数计算里:
if ($dish->boost_weight > 1) { $finalScore = $dish->alg_score * $dish->boost_weight; }4.4 接口联调抓包与调试技巧
小程序开发最难受的地方是没办法直接看浏览器 Network,所有接口请求都是黑盒。调试阶段我用抓包工具抓 HTTPS 请求,手机上装好证书后,能清楚看到小程序发给后端每个接口的完整参数和返回结果。
抓包时的经验:首先要确认小程序的合法域名是否配置正确。开发阶段可以在微信开发者工具里勾选“不校验合法域名”,但实际预览或者上线时必须在小程序后台配置 request 合法域名,并且接口必须 HTTPS。HTTP 接口在真机上直接不通,这个被坑过的人应该有一大批。
然后提一下微信开发者工具的 Network 面板,其实它自带一个调试器,但只显示小程序前端层面的请求状态,看不到服务器返回的具体数据结构。用抓包工具的好处是能完整看到 app 端到服务器端的数据流转,尤其是 PHP 接口报错时,响应内容到底返回了 HTML 错误页还是 JSON 错误信息,一目了然。
还有一个细节:小程序发布体验版之后,建议在测试机上开启调试模式抓一次正式环境包,因为体验版和开发版的请求域名策略不完全一样。这个环节很多文档没强调,但排查线上问题的时候非常关键。
5. 上线踩坑与性能优化实录
5.1 高频报错与排查思路速查
做完这个项目,整理了一下自己在开发过程中遇到的高频问题和排查思路。这些坑你十有八九也会撞上,提前看了能省好几个晚上的时间。
| 问题表现 | 根本原因 | 解决方法 |
|---|---|---|
| npm 命令提示禁止运行脚本 | PowerShell 执行策略限制 | Set-ExecutionPolicy RemoteSigned 当前用户 |
| 小程序请求接口报 502 | PHP 接口异常,返回了 HTML 错误页 | 检查 PHP 错误日志;关掉 display_errors 改为记录日志 |
| Vue 后台请求接口跨域 | 端口不同,缺少 CORS 头 | PHP 统一加响应头,预检请求 OPTIONS 也要处理 |
| 协同过滤推荐结果全是热门 | 行为数据稀疏,相似度计算退化为 0 | 冷启动用物品协同加热门兜底,不要硬算相似度 |
| 用户当天推荐没更新 | Redis 缓存过期时间过长 | 设置推荐缓存为每天凌晨计算后刷新,有效期设置为 12 小时 |
| 图片上传后显示失败 | 小程序临时文件路径已失效 | 上传后必须将临时文件复制到服务器存储目录,返回持久化 URL |
| 管理后台详情页刷新参数丢失 | 使用了 query 传参而路由没有动态段 | 改用动态路由/edit/:id获取参数 |
| Node 定时任务不执行 | 服务器时区偏移 | 在脚本内显式指定时区:TZ='Asia/Shanghai'或 node-cron 传 timezone |
5.2 算法计算的性能瓶颈与优化
协同过滤计算在数据量小的时候毫无压力,但用户量一旦过千、菜品铺到几百道,PHP 里纯数组循环计算相似度就会开始吃力。几个优化手段按性价比排序:
第一,向量稀疏化。用户-菜品矩阵里大量维度是 0,存完整的 m×n 矩阵非常浪费。我只存有值的位置,用哈希表存储。PHP 里直接用一个 dict 表示稀疏向量,键是菜品 ID,值是评分。这样相似度计算遍历的长度只跟用户实际行为数有关,而不是跟菜品总量有关。用户行为 30 天内的数据控制在几百条的规模,遍历成本极低。
第二,相似用户候选集裁剪。不需要拿当前用户和全量用户算相似度,只和他有过相同菜品行为的用户比较。SQL 里先查当前用户有行为的菜品集合,再查对这些菜品也有行为的用户集合,然后用IN条件取这批用户出来算相似度。这一步能把计算量缩小几个数量级。
第三,结果预计算。推荐结果全部走定时任务预计算,在线接口只是读取缓存。算法模块和在线服务模块彻底分离,算法怎么改都不影响线上稳定性,改动后重新跑一次定时任务即可。
Node.js 那边跑算法预处理同样要控制内存。PHP 脚本本身占内存不高,但如果你把整个相似度矩阵一次性加载进内存再计算,内存立刻飙到几十 MB 甚至上百 MB。我的做法是分用户批次处理,每次只取 200 个用户的数据算他们之间的相似度,批次跑完写库释放内存。大规模数据下这种“分而治之”比一次性全量计算靠谱得多。
5.3 数据质量比算法更重要
这是做推荐系统最深刻的一个体会。算法模型再花哨,行为数据采集不干净,输出的推荐结果就是垃圾。我在这套项目里最重视的不是余弦相似度实现,而是行为上报的准确性。
举个例子:用户在小程序里只是快速滑过某个菜品,曝光接口如果就上报了,矩阵里就会多出一条“伪反馈”。我处理曝光行为时做了去重:同一用户对同一菜品 10 分钟内只记一次曝光。点击行为则要防误触,进入详情页超过 3 秒才算一次有效点击,低于 3 秒就退出的大概率不是真实兴趣。
评分弹窗的设计也影响数据质量。一开始我把评分弹窗放在下单成功后立即弹出,用户体验并不好,评分数据偏低。后来改成延迟 10 秒弹出,并且一天最多弹 3 次,数据明显正常了很多。算法里最怕的不是用户不评分,而是被引导产生的低质量评分,这种数据会污染相似度计算。
这套项目做完之后,我又在数据清洗和推荐解释性上花了不少心思。比如推荐结果里加上“为什么推荐”的标签,不仅用户感知好,调试算法时也能快速定位是哪个相似的用户或哪个口味标签带来的推荐。后续如果想继续扩展,可以做用户画像标签系统,把口味偏好拆成“麻辣”“清淡”“甜口”等标签,在物品协同的基础上叠加标签过滤,推荐可解释性和准确性都能再上一个台阶。反正技术底座已经搭好了,玩出更多花样只是时间问题。