美食打卡小程序开发实录:个人主体也能上线的小程序实战
一、为什么做这个小程序
二、功能一览
三、技术架构总览
四、后端设计:单文件 Express 的优雅
五、前端设计:uni-app 六页搞定
六、地图选点:个人主体的避坑之路
七、踩过的五个坑及解决方案
八、部署到生产环境
九、写在最后

一、为什么做这个小程序
事情起因很简单:我想有个地方记录自己吃过的餐厅。
大众点评是给所有人看的,我想要一个只给自己看的版本——我的评分是我的真实感受,我的招牌菜是我真的推荐的,我的心愿单是我想去但还没去的。市面上的 App 都太重了,打开就要定位、要推送、要好友、要社区,我只是想打个卡而已。
作为一个程序员,遇到工具不顺手,当然是自己造一个。本文记录这个小程序从 0 到 1 的完整开发过程,重点是:这是一个个人主体也能上线的小程序——不需要企业资质、不需要类目审核、不需要云开发额度。如果你也持个人账号想做小程序,这篇文章应该能帮到你。
二、功能一览
店名/地址/评分/招牌菜/人均/点评
我的评分 vs 大众点评评分
选点后自动识别城市区县
想去 + 已打卡的”想再去”提醒
总数/平均分/消费/城市/标签云
已打卡 / 想去 / 不再去
关键词、城市、状态、标签、排序
每个账号独立数据空间
特别说一下“想再去”功能——这是用了一段时间后自己加上去的。发现有些店去过一次不够,过段时间想去第二次,但没有提醒就会忘。于是给已打卡的店加了一个独立的”想再去”标记,可以设一个回访计划日期,到期前会在心愿单顶部红色提醒。这个标记和”已打卡”状态是独立的,不会互相覆盖。
三、技术架构总览
| 层 | 选型 | 说明 |
|---|---|---|
| 前端 | uni-app (Vue3) | 一套代码编译微信小程序 / H5 |
| 后端 | Node.js + Express | 极简,单文件 460 行 |
| 数据库 | SQLite (better-sqlite3) | 零配置,文件即数据库 |
| 鉴权 | JWT + 微信 code2session | 账号密码 + 微信登录双模式 |
| 地图 | wx.chooseLocation + 腾讯地图 | 个人主体可用,无需类目 |
| 部署 | VPS + Nginx + pm2 | 甲骨文永久免费机即可 |
选这套栈的理由很简单:
- uni-app:写过 Vue 的人零成本上手,编译微信小程序没问题
- Express + SQLite:不需要任何云数据库,一个文件搞定,迁移备份只要拷贝文件
- 无云开发依赖:所有逻辑都在自己服务器上,不被任何厂商锁定
这套架构同样适用于任何轻量级小程序——笔记、记账、健身、读书、宠物记录……只要数据形态是”用户 + 用户私有数据”,都能直接套用。
四、后端设计:单文件 Express 的优雅
4.1 数据模型
两张表搞定:
users 用户表:username / password / display_name / openid
user_data 数据表:username 主键 + data 字段(JSON 字符串)
关键设计在 user_data 表——每个用户一行,所有业务数据存成一个 JSON 字符串。这样做的好处:
- 不需要为每个业务实体建表(餐厅、心愿单、统计快照全都塞 JSON)
- 改字段不用改数据库结构,前端要什么就往 JSON 里加什么
- 数据库迁移只要拷贝一个
.db文件
4.2 统一入口:POST /api/mp + action 分发
所有业务接口都走同一个 URL:POST /api/mp,请求体里带一个 action 字段区分业务。这是一个我从党建小程序延续过来的极简套路:
app.post('/api/mp', (req, res) => {
const { action } = req.body;
// JWT 鉴权
const user = jwt.verify(token, JWT_SECRET);
// 按 action 分发到对应 handler
const handler = mpHandlers[action];
const result = handler(user.username, body);
res.json({ code: 0, data: result });
});
所有业务逻辑挂在 mpHandlers 对象上,按命名空间组织:
const mpHandlers = {
'stats:summary' (username) { /* 统计工作台 */ },
'restaurant:list' (username, event) { /* 店铺列表 */ },
'restaurant:create'(username, event) { /* 新增店铺 */ },
'restaurant:update'(username, event) { /* 更新店铺 */ },
'restaurant:delete'(username, event) { /* 删除店铺 */ },
'restaurant:status' (username, event) { /* 切换状态 */ },
'restaurant:revisit'(username, event) { /* 切换想再去 */ },
'location:reverse' (username, event) { /* 逆地址解析 */ },
// ...
};
这种模式的优势:
- 前后端契约清晰:所有 action 列表就是全部 API 文档
- 鉴权集中:所有业务接口自动走 JWT 校验
- 支持异步:handler 返回 Promise 时自动等待(如调用外部地图 API)
- 错误统一:handler 里
return { error: '...' }自动转成 400 响应
4.3 微信登录
微信登录走标准的 code2session 流程,前后端配合:
// 前端
uni.login({
provider: 'weixin',
success: (res) => {
request('/api/wx-login', { code: res.code });
}
});
// 后端
app.post('/api/wx-login', async (req, res) => {
const { code } = req.body;
// 用 code 换 openid
const r = await fetch(`https://api.weixin.qq.com/sns/jscode2session?appid=${appid}&secret=${secret}&js_code=${code}`);
const { openid } = await r.json();
// 根据 openid 查用户
const user = db.prepare('SELECT * FROM users WHERE openid = ?').get(openid);
if (!user) return res.json({ code: 0, data: { needBind: true, openid } });
// 已绑定,签发 JWT
const token = jwt.sign({ username: user.username }, JWT_SECRET, { expiresIn: '30d' });
res.json({ code: 0, data: { token, user } });
});
needBind: true 时,说明这个微信号还没绑定账号——这时引导用户去注册或登录页,把 openid 一起带上,登录成功后自动绑定。五、前端设计:uni-app 六页搞定
前端只有六个页面:
| 页面 | 路径 | 功能 |
|---|---|---|
| 登录页 | pages/login | 账号登录 + 微信一键登录 |
| 打卡列表 | pages/index | 主页,餐厅卡片列表 + 筛选 |
| 店铺详情 | pages/detail | 评分快速调整 + 地图导航跳转 |
| 新增/编辑 | pages/edit | 表单 + 地图选点 |
| 心愿单 | pages/want | 想去 + 想再去的回访提醒 |
| 美食足迹 | pages/stats | 统计仪表盘 |
5.1 请求层封装
前端所有 API 调用统一走 utils/request.js,屏蔽掉 uni.request 的细节:
// 业务接口示例
const restaurants = await request.getRestaurants({
keyword: '火锅',
city: '上海',
status: 'visited',
sortBy: 'rating'
});
// 底层就是统一的 callCloud
function callCloud(action, data = {}) {
return new Promise((resolve, reject) => {
uni.request({
url: API_BASE + '/mp',
method: 'POST',
data: Object.assign({ action }, data),
header: { Authorization: 'Bearer ' + token },
success: (res) => {
if (res.data.code === 0) resolve(res.data.data);
else reject(new Error(res.data.error));
}
});
});
}
5.2 页面配色
用了一个暖色调主题(橙色 #FF6B35),符合美食场景的食欲感。tabBar 选中色、导航栏背景、按钮主色都统一用这一个色值,视觉一致性很容易做到。
六、地图选点:个人主体的避坑之路
这是整个项目最有挑战的部分,写在这里给同样持个人账号的开发者避雷。
6.1 坑一:getLocation 不能用
wx.getLocation 获取用户位置,结果发现这个 API 需要类目审核,个人主体的小程序根本通不过审核。wx.chooseLocation(地图选点)。这个 API 是让用户主动在地图上选一个点,属于”用户主动授权”的范畴,不需要类目审核,个人主体就能用。返回结果里直接带店名、地址、经纬度,反而比 getLocation 更好用。uni.chooseLocation({
success: (res) => {
// res 直接带 name/address/latitude/longitude
// 完全是新增打卡需要的字段
}
});
6.2 坑二:requiredPrivateInfos 配置报错
配置 requiredPrivateInfos 时一开始填了 openLocation,结果上传时报”非法值”。查文档才发现这个字段只在特定 API 列表里合法:getLocation、chooseLocation、chooseAddress 等少数几个,openLocation 不在内。
// manifest.json
"requiredPrivateInfos": ["chooseLocation"] // 只保留这一个
6.3 坑三:逆地址解析 403
地图选点返回的是经纬度,但我想自动填充城市和区县,需要做逆地址解析。前端调用腾讯地图 API 直接 403:
Referer: https://你的域名/ 头。前端只把经纬度传给后端,后端代理调腾讯地图 API 返回城市/区县/详细地址。顺便绕开了小程序对第三方域名的种种限制。// 后端 server.js 的 location:reverse handler
'location:reverse'(username, event) {
const { latitude, longitude } = event;
const key = process.env.TENCENT_MAP_KEY;
return fetch(`https://apis.map.qq.com/ws/geocoder/v1/?location=${latitude},${longitude}&key=${key}`, {
headers: { Referer: 'https://your-domain.com/' }
})
.then(r => r.json())
.then(json => ({
city: json.result.address_component.city,
district: json.result.address_component.district
}));
}
6.4 兜底方案:前端正则猜城市
考虑到不是所有人都会去申请腾讯地图 Key,前端还做了一个兜底——如果后端没配置 Key 导致逆地址解析失败,就从地图选点返回的地址字符串里正则提取:
function guessCityFromAddress(address) {
const m = address.match(/([^\s省市]{2,8}?[省市区]市|[^\s]{2,6}?自治区)/);
return m ? m[1] : '';
}
这样即使完全没配地图 Key,小程序也能正常打卡,只是城市要手动确认一下而已。
七、踩过的五个坑及解决方案
| 坑 | 症状 | 解决方案 |
|---|---|---|
| getLocation 不可用 | 上传被拒,提示需类目 | 改用 wx.chooseLocation |
| requiredPrivateInfos 非法值 | 上传报错”非法值” | 只保留 ["chooseLocation"] |
| 腾讯地图 403 | 前端调用返回 403/199 | 启用 WebServiceAPI + 加 Referer 白名单 + 后端代理 |
| 编译报 summer-compiler miss js file | 开发者工具一编译就崩 | 固定 libVersion: "3.16.2" + es6: false |
| pm2 restart 后 env 不生效 | 改了环境变量重启服务还是旧值 | 用 pm2 delete && pm2 start ecosystem.config.js |
第五个坑尤其值得展开——pm2 的 restart 命令不会重新加载环境变量,它只是重启进程并继承之前的 env。改了 ecosystem.config.js 之后必须用 pm2 delete <name> && pm2 start ecosystem.config.js,否则你以为生效了其实没生效,调试半天不知道为啥行为不对。
八、部署到生产
8.1 Nginx 反代配置
假设后端跑在 3200 端口,用 Nginx 反代到 /food/api/ 路径下:
location /food/api/ {
proxy_pass http://127.0.0.1:3200/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
8.2 pm2 ecosystem 配置
// ecosystem.config.js
module.exports = {
apps: [{
name: 'food-server',
script: './server.js',
env: {
PORT: 3200,
JWT_SECRET: 'your-random-secret-32-chars',
WX_APPID: 'your-appid',
WX_SECRET: 'your-secret',
TENCENT_MAP_KEY: 'your-map-key'
}
}]
};
8.3 一键部署脚本
# 本地推送后端到服务器
rsync -avz --exclude 'node_modules' --exclude 'food.db*' \
web-server/ oracle:~/food-server-upload/
# 服务器同步并重启
ssh oracle "sudo rsync -a ~/food-server-upload/ /opt/food-server/ && \
sudo chown -R www-data:www-data /opt/food-server && \
sudo pm2 restart food-server"
8.4 微信公众平台配置
- 登录 微信公众平台
- 开发 → 开发管理 → 服务器域名 → request 合法域名添加你的后端域名
- 本地调试时在开发者工具「详情 → 本地设置」勾选「不校验合法域名」
九、写在最后
做完这个小程序,最大的感受是:个人开发者做小程序没那么难。
很多人被”个人主体限制”劝退,觉得没有企业资质什么都做不了。实际上只要你愿意绕一下路(比如用 chooseLocation 替代 getLocation),大部分功能都能实现。微信对个人主体的限制主要在支付、直播、客服消息这类强商业化场景,普通的工具型、记录型小程序完全没问题。
技术上,这套”uni-app + Express + SQLite + 单文件后端“的组合,几乎可以复用到任何轻量级小程序。我之前做过党建小程序用的也是同一套架构,这次只是换了业务逻辑——前后端骨架基本没动,主要工作量在前端页面。
把这个套路沉淀下来的价值在于:以后再有”给自己做个小工具”的想法,从 0 到上线可能就一个周末的事。
- 个人主体小程序完全可以做,避开几个 API 限制即可
- uni-app + Express + SQLite 是轻量级小程序的黄金组合
- “POST /api/mp + action 分发”的后端模式值得复用
- 地图选点用
chooseLocation,逆地址解析放后端做
如果你也在做类似的小程序,或者对这套架构有疑问,欢迎在评论区交流。希望这篇文章对你有帮助 🙌







暂无评论内容