
简介一套带后端的简易记账微信小程序源码以日常收支记录为场景完整覆盖小程序前端、服务端接口与后台管理适合正在学习小程序开发、希望理解前后端数据交互的初学者及进阶者。压缩包共126个文件大小仅312KB主要包含C#业务逻辑与ASHX接口、config和json配置、JS/WXML/WXSS前端页面以及少量图片和工程文件结构紧凑可逐模块研读。源码已具备用户登录、账单新增和历史查询等核心功能后台侧支持记账数据管理开发者可在此基础上扩展分类统计或图表模块定制自己的记账小程序。已有46人学习下载仔细阅读可掌握小程序页面渲染、数据绑定与事件处理还能从ASHX接口中理解轻量级后端设计思路并通过后台管理模块学习简易控制面板的搭建方法。1. 记账小程序加上后端到底是在加什么记账小程序听起来是典型的纯前端活一个输入框、一个列表、一个统计页本地wx.setStorageSync存一存似乎就能跑。但真放到手机里用一个月问题就全出来了换手机账目全没、没法在别人的设备上看同一条记录、想按月按分类做汇总时前端那几千条数组卡得明显。所谓“带后端”本质是把“记一笔”升级成“存一笔”让数据有了归属、聚合和同步的能力而不是躺在某台手机的应用沙盒里。这套简易记账源码要解决的正是一个最小闭环小程序端负责录入和展示后端负责鉴权、落库和报表统计。适合两类人——想拿一套能跑通的前后端源码来改造成自己项目的人以及从纯前端往前后端联调迈步的开发。下面不铺架构图直接给能复现的建表语句、接口代码和联调参数。2. 后端选型与数据设计先把三张表建对2.1 为什么用 Express SQLite而不是一上来就 Spring Boot很多前端开发者第一次写后端时习惯性想选 Java Spring Boot因为教程多、招聘要求里也常见。但“简易记账”的接口量只有登录、记账、统计、分类管理这几个Spring Boot 的工程骨架、依赖管理、打包部署成本远超业务本身。常见做法是选 Node.js 的 Express 加 SQLiteExpress 几十行就能把路由搭完SQLite 是一个单文件数据库数据落地在ledger.db里备份直接复制文件迁移也只需要拷贝走这一个文件。这个选型还有一个实际好处接口复杂度与数据量都处在一个“一台低配云服务器跑得动”的范围里。等哪天用户量上来、需要多人协作或横向扩展时再迁到 Spring Boot 或 NestJS 加 MySQL 不迟数据模型层面不用返工。对这份源码来说后端部分建议拆成下面这样一个结构server/ app.js # Express 入口挂载路由 db.js # 初始化 SQLite 连接建表 auth.js # 登录与 token 校验中间件 config.js # appid、secret、jwt 密钥 package.json每个文件只干一件事。app.js里不写业务 SQL只做路由分发db.js负责建表和导出数据库实例auth.js里的中间件统一校验请求头里的 token。这样做的直接收益是你在app.js里找接口定义、在db.js里找表结构、在auth.js里改登录逻辑不需要在一个几百行的文件里上下翻。2.2 建表 SQLusers、categories、records 三张表一次到位设计记账应用的核心是“谁的钱、花在哪、花了多少、什么时候花的”。对应到数据表就是用户表、分类表和流水表。用户表存微信 openid分类表维护每个用户自己的收支分类流水表存每一笔账。下面这份 SQL 是第一版就能直接落地的完整建表语句CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, openid TEXT NOT NULL UNIQUE, nickname TEXT DEFAULT , created_at TEXT DEFAULT (datetime(now, localtime)) ); CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN (expense, income)), icon TEXT DEFAULT , sort INTEGER DEFAULT 0, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, category_id INTEGER NOT NULL, type TEXT NOT NULL CHECK(type IN (expense, income)), amount INTEGER NOT NULL, note TEXT DEFAULT , occurred_at TEXT NOT NULL, created_at TEXT DEFAULT (datetime(now, localtime)), FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (category_id) REFERENCES categories(id) ); CREATE UNIQUE INDEX idx_uniq_record ON records(user_id, occurred_at, category_id, type, amount);注意records表里最后那个唯一索引它专门用来防重复记账。用户在网络抖动时双击提交或者某个请求超时后前端自动重试都会产生“同一个人、同一时刻、同一分类、同一金额”的重复记录。这个索引会在第二次插入时直接触发UNIQUE constraint failed后端捕获到异常后返回一个明确的提示而不是让报表数据翻倍。amount字段用的是 INTEGER不是 REAL 或 DECIMAL原因后面说。2.3 金额用“分”存、分类单独建表报表才能少返工amount字段的类型是整个数据库设计的第一个分水岭。JS 的浮点运算在涉及“元”的时候会出现 0.1 0.2 不等于 0.3 的问题SQLite 的 REAL 类型同样存在精度隐患。等到做月度汇总时SUM(amount)一累加误差会被放大。业界最常见的做法是金额一律以“分”为单位存整数后端接口接收外部传入的“元”时做一次Math.round(Number(amountYuan) * 100)返回给前端时再除以 100。这样数据库里永远是整数运算不会出现小数位错乱。分类表独立成一张表的理由同样直接如果只在records表里存一个分类名字符串比如“餐饮”用户某天把它改名为“吃饭”历史记录里所有“餐饮”都要跟着改不然统计页会出现两个长得差不多的分类。独立分类表后每个记录只存category_id统计时JOIN categories一次拿到的永远是最新的分类名。这也让新用户初始化分类变得容易登录后如果没有分类记录后端自动插入“餐饮、交通、购物、工资”等几组默认分类即可。下面是records表字段说明后端接口和前端页面的字段都以此为准字段类型说明idINTEGER主键自增user_idINTEGER关联 users.id数据隔离的关键category_idINTEGER关联 categories.idtypeTEXTexpense 或 income用 CHECK 约束amountINTEGER金额单位分noteTEXT备注允许空字符串occurred_atTEXT业务日期如 2025-06-30 10:00created_atTEXT入库时间默认当前时间这里特别强调user_id的隔离作用所有业务查询都必须带WHERE user_id ?否则接口之间互相串数据查出来的是别人的账单。3. 后端接口实现登录、记账、月度统计一条龙3.1 微信登录接口code 换 openid再签发 token小程序端的wx.login只能拿到一个临时code后端拿这个 code 去微信的jscode2session接口换openid这个 openid 才是用户的真实身份标识。不要自己生成 userId 让前端传那样任何人都能伪造身份。下面是用 Express 实现的标准登录接口app.post(/api/auth/login, async (req, res) { const { code } req.body; if (!code) { return res.status(400).json({ message: 缺少 code }); } const { data } await axios.get( https://api.weixin.qq.com/sns/jscode2session, { params: { appid: process.env.WX_APPID, secret: process.env.WX_SECRET, js_code: code, grant_type: authorization_code } } ); if (data.errcode) { return res.status(401).json({ message: 微信登录失败 }); } let user db.prepare(SELECT * FROM users WHERE openid ?).get(data.openid); if (!user) { const info db.prepare(INSERT INTO users (openid) VALUES (?)).run(data.openid); user { id: info.lastInsertRowid, openid: data.openid }; } const token jwt.sign( { uid: user.id, openid: data.openid }, process.env.JWT_SECRET, { expiresIn: 7d } ); res.json({ token, userId: user.id }); });这个接口的逻辑链路是先校验参数再请求微信接口换取 openid然后查用户表查不到就插入新用户最后签发一个七天有效的 JWT。JWT_SECRET必须从环境变量里读不要硬编码进代码仓库否则谁都能伪造 token 访问别人的账目。注意这里axios是后端发出的 HTTP 请求和小程序端的wx.request是两码事。微信接口返回的errcode有几种常见情况比如 40029 表示 code 无效或已使用45011 表示调用频率限制调试时看到这些码就知道问题在哪一端。3.2 记账接口入参校验与 UNIQUE 防重复插入登录之后是核心的记账接口。这个接口的设计重点不在 SQL 本身而在入参校验和重复插入的兜底。前端传来的金额是以“元”为单位的小数后端必须先转成整数“分”再做入库app.post(/api/records, authMiddleware, (req, res) { const { categoryId, type, amountYuan, note, occurredAt } req.body; if (!categoryId || ![expense, income].includes(type)) { return res.status(400).json({ message: 参数不合法 }); } const amount Math.round(Number(amountYuan) * 100); if (!Number.isSafeInteger(amount) || amount 0) { return res.status(400).json({ message: 金额必须大于 0 }); } try { const info db.prepare( INSERT INTO records (user_id, category_id, type, amount, note, occurred_at) VALUES (?, ?, ?, ?, ?, ?) ).run(req.uid, categoryId, type, amount, note || , occurredAt); res.json({ id: info.lastInsertRowid }); } catch (e) { if (String(e.message).includes(UNIQUE)) { return res.status(409).json({ message: 重复提交请勿双击 }); } throw e; } });authMiddleware是登录接口之外所有接口的统一入口它的职责是从请求头取出Authorization: Bearer token解析出uid并挂到req上。校验顺序先看分类和类型再看金额是否为正整数最后才是插入数据库。amountYuan转amount时用Math.round而不是parseInt是因为0.29 * 100在浮点运算里是 28.9999999直接取整会变成 28 分Math.round能拿到正确的 29。捕获到 UNIQUE 约束异常时返回 409前端可以根据这个状态码提示“不要重复提交”而不是弹出一个看不懂的服务器错误。3.3 统计接口用一条 SQL 完成月度报表和分类汇总记账应用的统计需求基本固定在两块某个月一共花了多少、赚了多少这个月每个分类各自花了多少。这两块在 SQLite 里都能用一条查询完成不需要在前端做二次聚合app.get(/api/stats/monthly, authMiddleware, (req, res) { const month req.query.month; if (!/^\d{4}-\d{2}$/.test(month)) { return res.status(400).json({ message: month 格式应为 2025-06 }); } const row db.prepare( SELECT SUM(CASE WHEN type expense THEN amount ELSE 0 END) AS expense, SUM(CASE WHEN type income THEN amount ELSE 0 END) AS income FROM records WHERE user_id ? AND substr(occurred_at, 1, 7) ? ).get(req.uid, month); const list db.prepare( SELECT c.name, c.icon, SUM(r.amount) AS total FROM records r JOIN categories c ON c.id r.category_id WHERE r.user_id ? AND substr(r.occurred_at, 1, 7) ? AND r.type ? GROUP BY c.id ORDER BY total DESC ).all(req.uid, month, expense); res.json({ expense: row.expense || 0, income: row.income || 0, byCategory: list }); });日期过滤用了substr(occurred_at, 1, 7)做前缀匹配这样occurred_at只要统一存成YYYY-MM-DD HH:mm格式月份查询就变得很直接。数据量在十万条以内时这个写法完全够用超过之后再改成occurred_at ? AND occurred_at ?的范围查询配合索引。这里还有一个设计点occurred_at由前端传不取服务器当前时间。原因是用户可能补录昨天的账如果后端取new Date()补录就会被记到错误日期里。4. 小程序端联调登录态、请求封装与表单提交4.1 request 封装与自动重新登录到这一步项目就正式进入前后端分离项目实战的关键阶段小程序端完全不碰数据库后端完全不碰页面两端只通过 JSON 通信。小程序端第一个要处理的就是请求封装。直接在每个页面里写wx.request会让代码很快失控统一封装后baseURL、header、状态码处理都只出现一次// utils/request.js const BASE_URL https://your-domain.example.com; function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL path, method, data, header: { Authorization: Bearer wx.getStorageSync(token) }, success(res) { if (res.statusCode 401 !path.startsWith(/api/auth/)) { reLogin().then(() { request(path, method, data).then(resolve).catch(reject); }); return; } if (res.statusCode 200 res.statusCode 300) { resolve(res.data); } else { wx.showToast({ title: res.data.message || 请求失败, icon: none }); reject(res.data); } }, fail: reject }); }); }封装的逻辑是token 每次从wx.getStorageSync读取保证拿到的是最新值。当任意接口返回 401 时自动调一次登录流程换新 token然后把原来失败的请求重放一遍。首页、记账页、统计页里所有接口调用都走这一个入口。path.startsWith(/api/auth/)的判断是必要的否则登录接口本身返回 401 时会陷入“登录失败后重新登录”的死循环。对应的登录函数长这样let reloginPromise null; function reLogin() { if (reloginPromise) return reloginPromise; reloginPromise new Promise((resolve, reject) { wx.login({ success: (res) { if (!res.code) return reject(new Error(获取 code 失败)); request(/api/auth/login, POST, { code: res.code }).then((data) { wx.setStorageSync(token, data.token); wx.setStorageSync(userId, data.userId); resolve(data); }).catch(reject); }, fail: reject }); }).finally(() { reloginPromise null; }); return reloginPromise; }reloginPromise这个全局变量解决的是并发场景如果首页同时有三个请求都返回 401三个请求会同时触发reLogin没有防重入保护的话就会调三次wx.login拿到三个 code 一起发给后端后面的 code 会因为已被使用而报错。有了这个变量第二次和第三次调用直接复用第一次的 Promise。4.2 记账页表单与首页数据流记账页的表单提交是整个小程序端最需要抠细节的地方。金额输入框用typedigit只能保证弹起数字键盘但用户依然能输入多个小数点所以提交前还要做一次格式校验const submit () { if (!/^\d(\.\d{1,2})?$/.test(form.amount)) { wx.showToast({ title: 金额格式不正确, icon: none }); return; } request(/api/records, POST, { categoryId: form.categoryId, type: form.type, amountYuan: form.amount, note: form.note, occurredAt: form.date form.time }).then(() { wx.showToast({ title: 记好了 }); wx.navigateBack(); }); };正则^\d(\.\d{1,2})?$限定了金额只能是整数或最多两位小数从入口拦截掉后端也会拒绝的脏数据。提交时传的是amountYuan字段名直接标明单位避免前后端对“这个 number 到底是元还是分”产生误解。首页的数据拉取放在onShow而不是onLoad这是记账类页面最容易踩的坑。onLoad只在小程序冷启动或页面第一次创建时执行用户从记账页navigateBack回首页时不会重新触发列表里的数据还是旧的。onShow则每次页面显示都会执行正好满足“记完一笔回来看最新账目”的需求onShow() { this.loadSummary(); this.loadRecords(); }, loadSummary() { const now new Date(); const month ${now.getFullYear()}-${String(now.getMonth() 1).padStart(2, 0)}; request(/api/stats/monthly?month${month}).then((data) { this.setData({ expense: (data.expense / 100).toFixed(2), income: (data.income / 100).toFixed(2), byCategory: data.byCategory }); }); }expense / 100的除法要放在展示层做后端返回的永远是整数分。这里还有一个小体验细节如果用户第一次启动时后端进程正在冷启动首页会出现短暂的白屏改动刚进入的加载页时不要只调样式可以在app.js的onLaunch里先请求一次/healthz探活接口探活成功再跳转首页体验会顺滑很多。4.3 真机与开发者工具的差异域名、code 复用、缓存本地调试和真机运行之间的差异是前后端联调阶段浪费时间最多的地方。三件事最容易出问题开发者工具里默认会校验 HTTPS 证书和合法域名本地联调时可以在“详情 - 本地设置”里勾选“不校验合法域名”。但真机预览或体验版没有这个开关请求域名必须同时满足三个条件HTTPS、ICP 备案、已配置在小程序后台的 request 合法域名列表里。配置后通常有几分钟到十几分钟的生效延迟。wx.login的 code 有严格的有效期和一次性限制5 分钟失效用一次就作废。如果你用 Charles 抓包电脑端微信小程序能看到登录请求是否被重复提交同一个 code 短时间内出现在两个 POST 请求里第二个必然报 40029。request 封装里的reloginPromise正是为了堵住这个口子。storage 缓存里的 token 不要用“本地登录时间加七天”来判断过期。用户改系统时间、或者后端 JWT 密钥轮换后本地时间判断都不可靠。正确做法是以后端返回的 401 状态码为准前端只把 token 当字符串存着过期与否全部交给接口响应来决定。5. 部署与后端兜底pm2 保活、幂等、弱网缓存5.1 把 Node 后端跑在服务器上pm2 HTTPS源码在本地跑通之后面向真机就必须部署到公网服务器。Node 进程直接用node server/app.js启动有风险终端一关进程就没了进程崩溃也不会自动拉起。常见做法是用 pm2 做进程守护npm install -g pm2 pm2 start server/app.js --name ledger-api pm2 save pm2 startup三条命令各管一件事pm2 start启动并命名进程pm2 save保存当前进程列表以便重启后恢复pm2 startup生成开机自启脚本。之后查日志用pm2 logs ledger-api重启用pm2 restart ledger-api不需要额外装监控面板。HTTPS 部分用 Nginx 反代到本地端口最省事。小程序要求请求域名必须是 HTTPS 且不能带端口Nginx 监听 443 后把请求转发给 Node 进程server { listen 443 ssl; server_name your-domain.example.com; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }证书配置好之后把域名填进小程序后台“开发管理 - 服务器域名”的 request 合法域名列表。记住这里填写的必须是 HTTPS 完整域名不带路径不带端口。5.2 双击防重复数据库唯一索引是最后一道闸前端做了 loading 提交态后端依然要防重复记账因为小程序端的按钮状态控制不住弱网重试和用户手速。records表上的唯一索引已经把“重复”的定义固化到了数据层同一个人、同一笔金额、同一个分类、同一个业务时间只允许出现一次。后端捕获到 UNIQUE 冲突后返回 409前端 toast 提示用户“这笔账已经记过了”。如果不想让用户看到错误提示也可以用INSERT OR IGNORE让重复的插入静默失败但代价是用户会疑惑“我明明点了保存怎么没反应”。我更推荐把 409 明确抛出来让用户知道是重复操作。这里不要用 Redis 分布式锁去解决记账应用的并发量远没到需要分布式锁的程度一个唯一索引已经是成本最低、效果最确定的方案。5.3 弱网先写本地再用 wx.env.user_data_path 兜底记账场景有个特殊性用户可能在地铁、电梯里掏出手机记账网络并不总是可用。与其让用户看着“请求失败”干瞪眼不如先把账存到本地等网络恢复再同步。wx.env.user_data_path是小程序为每个用户分配的文件目录不需要隐私授权写入后卸载小程序才会清除适合当草稿箱const fs wx.getFileSystemManager(); const pendingFile wx.env.USER_DATA_PATH /pending_records.json; function savePending(record) { let list []; try { list JSON.parse(fs.readFileSync(pendingFile, utf8)); } catch (e) {} list.push(Object.assign({ uuid: Date.now() Math.random().toString(16).slice(2) }, record)); fs.writeFileSync(pendingFile, JSON.stringify(list), utf8); }每条本地记录加一个uuid作为唯一标识同步到后端时后端把这 uuid 存进records表并加唯一约束就能避免“请求超时后重发导致重复入库”。网络恢复的监听点建议放在app.js的onLaunch里用wx.onNetworkStatusChange注册一次不要在多个页面里重复监听。同步成功后执行fs.unlink删掉pending_records.json这样即使下次同步失败也不会把已经入库的记录再传一遍。本文还有配套的精品资源点击获取