ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Node.js的微信小程序美食点评系统:毕设选题与全栈实现

基于Node.js的微信小程序美食点评系统:毕设选题与全栈实现

每年开题季,我都被问同一个问题:毕设做什么题目比较好过?如果你不想卷算法、不想写那种一眼看到头的管理信息系统,还想在项目里学到点能直接写到简历上的东西,那基于Node.js的微信小程序美食点评项目,是我这几年看下来最稳妥的选择之一。这篇文章不谈虚的,我尽量把从选题拆解、需求梳理、数据库设计、后端接口实现,到小程序页面落地、真机调试、答辩准备的完整链路讲透。全文五千来字,适合正在选题的本科生,也适合想快速上手一个全栈项目的自学者。

这个项目的本质,就是做一个美食版的大众点评类小程序:用户打开小程序能看到餐厅列表,进入详情页看招牌菜和真实评价,可以自己发评价带图片,能收藏想去吃的店,还能按评分看榜单。功能看起来简单,但背后涉及小程序登录鉴权、RESTful API设计、多表关联查询、文件上传、前后端联调这些实打实的工程环节。最关键的是,这个组合踩中了企业里最日常的技术栈:小程序做前端入口,Node.js做后端服务,MySQL存数据。做完一遍,你脑子里的全栈知识体系基本就成型了。

1. 项目整体拆解:毕设级美食小程序到底要做什么

1.1 需求清单:找对核心业务,别急着堆功能

做毕设最容易犯的错,就是功能越加越多。很多同学幻想把App里所有功能都塞进小程序,用户管理、订单管理、活动营销、优惠券……菜单里密密麻麻排满CRUD,看起来工作量很足,实际上答辩老师一眼就能看出你在堆砌。

美食点评类项目的核心业务,本质上只有一条闭环:用户找到想吃的餐厅,看别人的评价,自己动手写评价,把喜欢的店收藏起来下次再去。

围绕这条闭环,整个项目只需要三大块:

  • 小程序端(核心展示入口):餐厅列表、分类筛选、餐厅详情、招牌菜展示、评价列表、发布评价、收藏功能、个人中心。
  • 后端服务(Node.js + Express):对外提供全部接口,负责登录鉴权、数据读写、搜索排序、图片上传。
  • 数据库(MySQL):存储用户、餐厅、菜品、评价、收藏五类核心数据。

把这三大块拆清楚之后,工作量基本就有数了。小程序端十来个页面,后端十来个接口,两个核心业务闭环,再配上一份设计说明文档,就是一套非常标准的毕业设计体量。想冲高分别急着加功能,把已有模块打磨扎实,比新增十个半吊子功能有用得多。

1.2 技术选型的逻辑:Node.js + 微信小程序,为什么能帮你省力

这个组合最大的优势,是前后端都用JavaScript。你不需要在脑子里来回切换两套语言。小程序端写的逻辑代码是JavaScript,后端跑Node.js也是JavaScript,语法层面的认知负担非常小。对于本科阶段没有太多生产经验的毕设场景,这一点至关重要。

另一个理由是生态成熟、出问题好查。Express是Node最经典的框架,文档量大、中间件丰富,从路由到文件上传都有现成方案。微信小程序的官方文档同样完整,连登录流程都有现成的代码片段可以抄。一个平时不太写后端的学生,在学校实验室照着教程一步步来,两三周跑通一个可用版本,完全是可以实现的。

还有一点很现实:这个技术栈贴近企业真实用人需求。现在很多中小团队的标配就是小程序做前端、Node.js做中间层、MySQL做存储。做完这个项目,你简历上可以写"独立开发并完成一款美食点评微信小程序",面试官顺着项目经历能问的东西非常多,这是纯的管理系统类毕设给不了的优势。

2. 数据模型与接口设计:开工前先画好蓝图

2.1 数据库表结构:五张表怎么撑起一个点评系统

我习惯把数据库设计放在整个项目的第一步,比写代码早得多。原因很简单:表结构定下来,接口的字段就定下来了,小程序端的数据绑定也就跟着定下来了。后边改代码容易,改表结构要牵动的文件太多了。

美食点评项目的基础表不用多,核心就是五张。

用户表(user)负责存小程序用户的身份信息:主键id、openid、昵称、头像、性别、注册时间。openid是微信登录后拿到的用户唯一标识,整个项目里所有业务数据的归属都靠它来关联。

餐厅表(restaurant)是首页数据的主要来源:主键id、餐厅名称、分类(火锅/烧烤/日料/咖啡)、人均价格、评分、地址、营业时间、联系电话、封面图URL、创建时间。评分这个字段在首页列表和排行榜里都要用,属于高频查询字段,建议建表时直接加索引。

菜品表(dish)记录每个餐厅的招牌菜:主键id、餐厅id、菜品名称、图片URL、价格。为什么单独立一张表?因为点评类应用里,用户进到餐厅详情页最关心的就是这家店有什么招牌菜。有了菜品表,详情页的内容就有了支撑,不再只是干巴巴的文字信息。

评价表(review)是整个项目数据价值最高的表:主键id、用户id、餐厅id、评分(1到5)、评价文本、图片组、消费人均、点赞数、发布时间。发布评价是小程序端最复杂的交互点,因为它涉及评分联动、文本长度限制、图片上传三件事。

收藏表(favorite)结构非常简单:主键id、用户id、餐厅id、收藏时间。个人中心页展示"我收藏的餐厅"时,只要有用户id关联查询就能拿到。

五张表,互相通过id关联,血缘关系很清晰。这比动不动建十几张表的系统清爽得多,答辩讲起来也容易讲明白。我见过最典型的反面教材,是不管三七二十一先建一张超级大表把所有字段塞进去,后续加功能再拆表,结果各种外键和关联查询全乱了。记住一句话:表结构永远为业务闭环服务,不为"看起来全面"服务。

2.2 接口设计:十二个接口覆盖全部业务闭环

后端接口我按业务域来划分,数量上刻意做了克制。接口少而全,联调轻松;接口多而杂,光是前后端对齐就能磨掉你一半的耐心。下面这套清单是我实际跑通过好几轮的方案,可以直接搬。

模块接口路径方法功能说明
用户/api/user/loginPOST微信登录,code换openid,首次自动注册
用户/api/user/infoGET获取当前用户信息
用户/api/user/infoPUT修改昵称和头像
餐厅/api/restaurant/listGET餐厅列表,支持分页和搜索
餐厅/api/restaurant/detailGET餐厅详情,带招牌菜列表
餐厅/api/restaurant/sortedGET评分排行榜,按评分倒序取前10
评价/api/review/listGET某个餐厅的评价列表
评价/api/review/createPOST发布评价,支持带图
收藏/api/favorite/listGET我的收藏列表
收藏/api/favorite/addPOST添加收藏
收藏/api/favorite/cancelPOST取消收藏
文件/api/uploadPOST图片上传

接口控制在12个左右,页面再多都够用了。所有接口返回数据统一走一套格式:

{ "code": 0, "message": "success", "data": {} }

code为0表示请求成功,非0表示失败,message里放失败原因。小程序端只封装一个request函数,统一判断code,根据code决定走业务逻辑还是弹错误提示,不需要每个接口单独写一遍错误处理。

这里额外提醒一个细节:评价发布里带的图片,不能依赖数据库直接存文件路径,要单独做一个POST /api/upload接口,后端用multer中间件接文件流、保存到服务器磁盘、返回可访问的URL,前端拿到URL之后再和评价文本一起提交。这个上传接口在答辩演示的时候特别拉好感,因为你现场选图、上传、页面刷新出图片,整个交互链路完整,比纯文字评价的演示效果好得多。

3. 实操实现:从空目录到可运行项目

3.1 Node.js后端搭建:Express + MySQL的骨架

我推荐一套自己跑了很多遍都没出问题的组合:Express + mysql2 + multer + jsonwebtoken。安装就用一条命令:

npm install express mysql2 multer jsonwebtoken cors --save

这里的cors中间件一定要装。小程序真机调试走的是wx.request,很多情况下会涉及跨域请求,不加cors就是各种诡异报错。

主入口文件的结构可以参考下面这个写法:

const express = require('express'); const cors = require('cors'); const userRouter = require('./routes/user'); const restaurantRouter = require('./routes/restaurant'); const reviewRouter = require('./routes/review'); const favoriteRouter = require('./routes/favorite'); const app = express(); app.use(cors()); app.use(express.json()); app.use('/uploads', express.static('uploads')); app.use('/api/user', userRouter); app.use('/api/restaurant', restaurantRouter); app.use('/api/review', reviewRouter); app.use('/api/favorite', favoriteRouter); app.listen(3000, () => { console.log('server running at http://localhost:3000'); });

这个骨架里藏着三个特别容易被忽略的细节:

第一,express.json()必须加,否则POST请求传上来的JSON数据在req.body里怎么都拿不到,返回给你一个undefined。第二,静态资源目录要单独用app.use('/uploads', express.static('uploads'))暴露出去,不然你辛苦上传的图片在浏览器和小程序里都访问不了。第三,cors不能省,原因上面说了,真机调试和后续演示全靠它。

数据库连接单独抽一个db.js文件,用mysql2的promise版连接池:

const mysql = require('mysql2/promise'); const pool = mysql.createPool({ host: 'localhost', user: 'root', password: 'yourpassword', database: 'food_comment', waitForConnections: true, connectionLimit: 10 }); module.exports = pool;

为什么一定要用连接池而不是每次查询都重新createConnection?因为频繁创建连接非常消耗资源,在高并发请求下会直接把MySQL连接数打满,报Too many connections错误。连接池让多个请求复用预先创建的连接,用完放回池里,是工程里的标准做法。毕设体量下10个连接完全够用。

3.2 核心接口实现:登录与餐厅列表

微信小程序登录是有固定流程的,这个流程必须理解到位:

  1. 小程序端调wx.login()拿到临时code。
  2. 小程序把code发给后端。
  3. 后端拿code去微信官方接口换openid和session_key。
  4. 后端用openid去用户表查,查不到就自动注册新用户。
  5. 后端签发一个token返回给前端,后续所有请求都带上token。

用Node.js实现的核心代码思路如下:

router.post('/login', async (req, res) => { const { code, nickname, avatar } = req.body; const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${APPID}&secret=${SECRET}&js_code=${code}&grant_type=authorization_code`; const { data } = await axios.get(url); const openid = data.openid; const conn = await pool.getConnection(); const [rows] = await conn.query('SELECT * FROM user WHERE openid = ?', [openid]); if (rows.length === 0) { await conn.query( 'INSERT INTO user (openid, nickname, avatar) VALUES (?, ?, ?)', [openid, nickname, avatar] ); } conn.release(); const token = jwt.sign({ openid }, 'your_secret_key', { expiresIn: '7d' }); res.json({ code: 0, message: 'success', data: { token } }); });

这段代码里有两个关键点。第一个,code换openid这一步必须由后端请求微信服务器完成,APPID和SECRET坚决不能写在小程序前端代码里,否则被审核发现或者被人抓包,你的小程序会直接被封掉或被盗用。第二个,token有效期我习惯设7天,过期之后前端拿到401错误就提示用户重新登录。

再看餐厅列表接口。这个接口要同时支持关键词搜索、分类筛选和分页,SQL条件是动态拼接的,一个干净且好扩展的写法是这样:

router.get('/list', async (req, res) => { const { keyword = '', category = '', page = 1, pageSize = 10 } = req.query; let sql = 'SELECT * FROM restaurant WHERE 1=1'; const params = []; if (keyword) { sql += ' AND name LIKE ?'; params.push(`%${keyword}%`); } if (category) { sql += ' AND category = ?'; params.push(category); } const start = (page - 1) * pageSize; sql += ' LIMIT ?, ?'; params.push(Number(start), Number(pageSize)); const [rows] = await pool.query(sql, params); res.json({ code: 0, message: 'success', data: rows }); });

很多新手不理解这个1=1是什么意思。实际上它是动态SQL拼接的一个经典技巧:有了它,后面每个追加条件都统一用AND开头,不用单独判断是不是第一个条件。少了一堆if-else,代码的可读性和可维护性都上去了。

3.3 小程序端:页面结构与核心交互的落地

小程序端的页面规划,我建议直接用tabBar四个主导航:首页、榜单、发布、我的。

有人会问,常规逻辑不是首页加我的就够了吗?这里多出来的两个tab是有讲究的:榜单页能展示评分排序的能力,发布页能展示图片上传和多表写入的能力,这两个交互点是答辩时最容易讲出彩的地方,砍掉的话整个功能闭环就不完整了。

核心页面拆开来看:首页是横向分类栏加上纵向餐厅卡片列表,卡片展示封面图、名称、分类标签、人均价、评分星标。详情页包含封面轮播、基础信息、招牌菜列表、评价列表、底部收藏按钮。发布页要有交互式的评分选择、评价文本输入、最多9张图片上传和提交按钮。个人中心放用户卡片、我的收藏列表、我的评价记录和退出登录。

小程序端建议把网络请求统一封装成一个request函数:

const request = (url, method, data) => { return new Promise((resolve, reject) => { wx.request({ url: baseUrl + url, 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.message, icon: 'none' }); reject(res.data); } }, fail: reject }); }); };

这里有一个前后端必须严格对齐的约定:token在header里的字段名、错误码的判断逻辑、data包裹层的结构。任何一个不一致,联调阶段就是灾难现场。我每次做项目的习惯是,先定接口文档,前后端同时对照文档开工,目的就是彻底避免这种不一致。

4. 调试运行与常见坑:毕设最耗时的其实是联调

4.1 小程序端最容易卡住的三个问题

第一个是域名校验拦截。微信开发者工具默认勾选了"不校验合法域名",所以本地localhost请求都能通过。但一旦你把这个选项关闭或者直接上真机调试,所有请求都会被拦截,页面上一片空白,控制台报request:fail。毕设阶段通常没有备案的HTTPS域名,稳妥做法是同步做两件事:开发阶段全程开着"不校验合法域名",演示前用预览二维码在手机上跑通全流程。

第二个是wx.request返回中文乱码。排查思路其实很简单:先看开发者工具的网络面板,看response里返回的原始内容是什么。如果后端接口返回正常中文,问题一定在前端解析环节;如果后端返回的就是乱码或UTF-8格式不对的内容,问题就在响应头没有正确设置Content-Type。Node端一般用res.json返回不乱码,但如果你在某些情况下用了res.send,务必显式设置res.set('Content-Type', 'application/json; charset=utf-8')。

第三个是图片上传后访问不到。很多同学本地后台上传图片成功后,小程序里image组件的src却显示不出来。绝大部分原因是你把图片URL存成了相对路径,比如/uploads/xxx.jpg,而前端解析时不知道你服务器域名。正确做法是存完整地址,例如http://localhost:3000/uploads/xxx.jpg,保证任何页面都能直接访问到。

4.2 Node.js后端常见的逻辑坑

毕设体量下,后端并发量很低,通常不需要做复杂的性能优化,但有几个逻辑坑几乎人人都会踩。

第一个是登录态方案的选择。我直接建议用token,不要用session。session需要服务端保存状态,多一张session表要维护,还要处理过期策略。token方案直接由jwt生成,无状态、不需要额外存储,前端存storage里,后端在中间件里统一校验,实现成本低很多。

第二个是MySQL连接打满的问题。很多同学一开始用mysql.createConnection写查询,单个请求没事,页面一刷新并发几个请求,立刻报Too many connections。解决办法在前面已经说过了,用连接池,这是必须的。

第三个是文件上传完全没有限制。multer如果不做任何配置,别人可以传任意大小的文件把磁盘塞爆。开发阶段虽然没人攻击你,但答辩老师如果问到"你的上传接口怎么保证安全",你能说出这几个点就说明你有工程意识:限制单文件大小在5MB以内、限制mime类型只允许jpg和png、文件重命名为时间戳加随机数、保存到独立uploads目录。

4.3 答辩准备:老师最爱追问的三个技术点

第一个,为什么选Node.js而不是Java或Python?合理答案可以从三个角度给:前后端统一JavaScript语言降低维护成本;Node.js基于异步非阻塞IO模型,适合处理大量IO密集型场景(图片上传、数据查询);微信小程序原生就是JavaScript,技术栈匹配度高。回答这个问题的关键是要把"为什么"讲清楚,而不是背概念。

第二个,项目里最复杂的业务逻辑是什么?最稳妥的回答是评价发布与列表排序。发布评价涉及图片上传、多表关联查询(用户表、餐厅表、评价表)、按时间或评分动态排序,数据链路完整。对着代码把你实现的过程讲一遍,老师基本就满意了。

第三个,如果用户量上来,哪些地方需要优化?可以从加Redis缓存热点餐厅数据、MySQL读写分离、图片走对象存储、接口加防爬和频率限制这几个方向回答。不需要每个都展开做,但能提出来,就说明你有工程扩展意识。

4.4 从毕设到可扩展:还能往哪些方向定制

标题里提到定制,我把最常做的几个扩展方向列出来,顺便说一下大概的工作量。

  • 加地图导航功能:调用小程序内置地图组件,给餐厅详情页加一键导航按钮,工作量很小。
  • 加排队取号:在线取号、叫号状态查询,需要新增一张排队表和两个接口,属于中等扩展量。
  • 加商家端小程序:商家可以登录管理自家餐厅信息和回复评价,等同于再做一个管理端,工作量接近翻倍,时间紧张的同学不建议尝试。
  • 加推荐逻辑:基于用户收藏和评分记录做一个简化版的协同过滤推荐,适合想冲优秀等级的同学,但需要把算法原理讲清楚。

扩展功能不要贪多,做一个你完全搞懂的就够了。死磕一个功能点做到真正跑通,比同时做三个半吊子功能要好得多。

5. 手把手过一遍检查清单与掏心窝的避坑经验

5.1 一份可以直接抄的上线检查清单

每次做项目到最后阶段,我都会对着这份清单逐项打勾,你可以直接拿去用。

检查项具体要求
数据库初始化脚本单独整理成sql文件,别人拉下来能一键建表
接口文档更新到最新状态,和代码完全一致
README说明写清环境要求、启动命令、数据库账号配置方式
启动脚本不能依赖绝对路径,换一台机器也能启动
小程序配置确认当前AppID是能正常登录的测试号或正式号
图片路径所有图片URL都是完整可访问地址
代码注释每个路由文件头部写清功能职责

5.2 我踩过最狠的两次坑,提前说给你们

做类似项目时,我经历过最崩溃的一次,是小程序端所有POST请求全部返回400。查了半天,最后发现是header里的Content-Type写成了application/x-www-form-urlencoded,而后端用的是express.json(),两者不匹配,body解析失败。后来把Content-Type统一改回application/json,问题立刻消失。这个坑看着很基础,但它真的会出现,尤其在你复制别人的代码片段时更容易踩中。

第二个坑是微信小程序的临时文件路径。chooseImage接口拿到的tempFilePath只在当前小程序运行生命周期里有效,不能直接存进数据库。不少同学图省事,把tempFilePath当永久路径保存,第二天再打开小程序,图片全变成空白。正确做法是先把临时路径通过wx.uploadFile上传到后端,拿到服务器返回的永久URL,再把永久URL写入数据库。

这两句话看起来短,但每一个都是实打实熬过夜换来的经验。希望你们做毕设的时候,能少熬几次这种本没必要的夜。

最后根据我个人的体会,这个项目最值得做的地方,不是代码写得多花哨,而是它把一套完整的全栈流程从头到尾串了下来:你会清楚数据库为什么这么设计,接口为什么这么拆,小程序页面为什么这么组织。做完它,你就不再是一个只会写页面或者只会写接口的人,而是一个真正能独立交付一个完整应用的人。这大概也是老师们出这种题目的本意。祝各位开题顺利,答辩一次通过。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进