做后端开发这几年,接得最多的兼职就是小程序订单类的项目。小程序商城、预约小程序、点餐小程序,核心都是那套东西——登录、下单、支付、订单管理。说实话,这种项目技术难度不算高,但坑是真不少:微信登录的 session_key 过期、支付回调幂等没做好、订单状态被并发改错……每个坑都能让你在交付那天被甲方问到怀疑人生。
这篇就把我做了七八个小程序订单后端之后沉淀下来的方案完整拆一遍。从需求分析、数据库设计,到微信登录、微信支付、状态机流转,再到联调和交付,都按真实项目里的做法来讲,代码和 SQL 也直接给了。适合正在找兼职接单方向的后端朋友,也适合自己练手做全栈的个人开发者。
1. 项目背景:为什么选“订单后端”作为接单切入点
1.1 小程序订单类项目在接单市场的真实需求
打开任一家外包平台或者你所在的技术接单群,你会发现小程序相关的单子长期稳定地占着一大块比例。其中订单类项目尤其多,因为小程序天然适合做交易场景,商城、预约、外卖、到店核销,本质上都是一套“用户选商品、下单、支付、查订单”的流程。
而且这类单子有一个很现实的特点:需求方往往是线下实体商家或者刚起步的创业者,他们不太可能一开始就找大厂定制,预算也有限,更愿意花几千到两三万找一个人或一个小团队把东西做出来跑通业务。对你来说,订单后端正好是不需要重度行业知识的领域,你不需要懂医疗、不用懂教育,只要把支付流程、订单状态管理做扎实,就能覆盖一大半客户的需求。
从扩展性上说,订单后端也是一个可以复用的核心模块。你接完一个商城单,下一个预约单基本就是改改商品模型、调调状态;后面再做外卖单、到店单,都是在同一套骨架上换皮。所以第一个订单项目选这个方向,后续接单效率是肉眼可见的提升。
1.2 项目范围与功能边界定义
接单最容易翻车的地方,不是写代码,而是没在动工前把“做什么”和“不做什么”讲清楚。以一个小程序订单后端为基准,我一般会把项目范围收敛成下面几块:
- 微信登录:小程序端
wx.login拿 code,后端调微信接口换 openid 和 session_key,解决用户身份问题。 - 商品/服务列表:小程序首页展示商品,后端提供分类、详情接口。这个模块有些单子是没有的,需求方可能直接给固定套餐,但接单时我建议保留一个最小实现,不然没法测下单。
- 创建订单:校验商品是否有效、库存是否够,生成业务订单号,计算金额,落库。
- 微信支付:调微信支付 JSAPI 下单,返回小程序端需要的支付参数;处理支付结果回调,更新订单状态。
- 订单查询:用户在小程序端查看订单列表、订单详情。
- 管理端接口:给需求方一个简单的后台,看订单、改发货状态、处理售后。这块如果完整做就是一个独立项目,兼职单子里通常只做几个查询和状态流转接口,管理界面用现成的 admin 框架套一下就行。
这份清单在接单沟通阶段就要发给客户确认。凡是清单外的功能,比如优惠券、积分、分销、秒杀,要么加钱要么明确写出“不在第一期范围内”。我见过太多兼职开发者把优惠券和拼团当成默认功能,结果交付时被要求免费补上,工期直接翻倍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术方案与架构设计
2.1 后端框架与运行环境选型
接到单子第一件事是定技术栈,这个决定直接影响你的交付成本和后续维护。
Node.js + Express 是我个人最推荐的,原因有几个:第一,微信官方提供的 SDK 和文档对 JS 的支持最完整,遇到问题能直接照着官方示例调;第二,前后端同语言,如果你自己写小程序前端,调试时心智负担小很多;第三,部署轻量,一台 2G 内存的云服务器跑 Node 服务加一个小型数据库完全够用,服务器成本低,客户也更容易接受。
Java Spring Boot 我也用过,适合客户公司内部本来就有 Java 团队、后续要交接的情况,但个人接单时启动一个 Spring Boot 项目对服务器要求相对高一些,编译打包也比 Node 繁琐。Python FastAPI 做这类项目同样可行,但微信支付 API v3 的文档参考示例以 Java 和 Node 为主,遇到冷门 case 时找资料的时间成本会高一些。
数据库选 MySQL 8.0,这个没有争议。订单类数据要求强一致性和事务支持,用 MySQL 的 InnoDB 引擎做事务管理,配合 Redis 做缓存和防重,是最成熟稳重的组合。Redis 不是必须的,但如果项目里有库存扣减、秒杀、或者需要高频读取热数据,一定要加。
服务器建议买一台 2C4G 的云主机,装 Ubuntu 22.04,用 Nginx 做反向代理,Node 进程用 PM2 托管。整一套东西下来,单月成本控制在几十块以内,接一个单子就完全回本了。
2.2 数据库设计与订单状态机
数据库设计是整个订单后端的灵魂。我见过很多新手一上来就设计一张巨大的 orders 表,把所有字段塞进去,结果后面做退款、做售后、做多商品订单时全都卡住,最后不得不重构。
推荐的做法是拆分三张核心表:用户表、订单表、订单明细表。
用户表的设计重点是 openid 的唯一索引,以及预留 unionid 字段方便以后做公众号、小程序多端打通。
sql复制CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`openid` varchar(64) NOT NULL COMMENT '微信openid',
`unionid` varchar(64) DEFAULT NULL COMMENT 'unionid,多端统一身份用',
`nickname` varchar(64) DEFAULT '' COMMENT '微信昵称',
`avatar` varchar(255) DEFAULT '' COMMENT '头像地址',
`phone` varchar(20) DEFAULT '' COMMENT '手机号',
`session_key` varchar(128) DEFAULT '' COMMENT '最近一次登录的session_key',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_openid` (`openid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信用户表';
订单表有两个容易踩坑的地方。一是“业务订单号”和“数据库自增 ID”要分开,业务订单号是展示给用户、用来查单和对账的,必须有唯一索引;二是支付状态和订单状态要分开存,因为订单可以部分退款、可以发货后被取消,一个状态字段根本表达不了这些业务语义。
sql复制CREATE TABLE `orders` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL COMMENT '业务订单号',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`total_amount` decimal(10,2) NOT NULL COMMENT '实付金额(元)',
`pay_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '支付状态:0待支付 1已支付 2已退款',
`order_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '订单状态:0待发货 1已发货 2已完成 3已取消',
`pay_time` datetime DEFAULT NULL COMMENT '支付时间',
`transaction_id` varchar(64) DEFAULT NULL COMMENT '微信支付单号',
`cancel_reason` varchar(255) DEFAULT '' COMMENT '取消原因',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
订单明细表存的是下单那一刻的商品快照——商品名称、单价、数量。为什么要快照?因为商品表里的价格和名称是会变的,你月底想查“这个订单当时买的是什么”,如果只存一个 goods_id,商品改版后你根本查不出当时用户看到的到底是什么。
sql复制CREATE TABLE `order_item` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_id` bigint(20) NOT NULL COMMENT '订单ID',
`goods_id` bigint(20) NOT NULL COMMENT '商品ID',
`goods_name` varchar(128) NOT NULL COMMENT '商品名称快照',
`goods_price` decimal(10,2) NOT NULL COMMENT '成交单价快照',
`quantity` int(11) NOT NULL DEFAULT '1' COMMENT '数量',
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';
订单状态机是业务逻辑里最核心的部分。我的习惯是把状态流转画成一张图贴在代码注释最上方,编码时严格按流转来,不允许随意跳转。
支付状态:0 待支付 -> 1 已支付 -> 2 已退款,这个链路比较单一。订单状态复杂一些:0 待发货 -> 1 已发货 -> 2 已完成,以及从 0 待支付 可以直接到 3 已取消。还有一个隐藏情况是支付成功但用户申请退款,订单状态会变成“已取消”,但支付状态变成“已退款”。这两个状态字段组合起来才能完整表达真实业务。
我建议在代码里单独写一个状态机类或者配置表,把所有允许的迁移路径列出来,每一步校验当前状态和操作是否匹配。否则一旦业务变复杂,到处都是 if 分支,出问题你根本查不过来。
3. 核心接口实现:从登录到支付
3.1 微信登录与用户体系设计
小程序端点击“微信一键登录”的流程,后端要做的事其实只有一件:拿前端传过来的 code,去微信的 jscode2session 接口换 openid。这个接口的调用方式非常固定:
javascript复制// 依赖 axios,路由 POST /api/auth/login
app.post('/api/auth/login', async (req, res) => {
const { code, userInfo } = req.body;
if (!code) {
return res.status(400).json({ code: 400, msg: '缺少code' });
}
const wxResult = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
params: {
appid: config.appid,
secret: config.secret,
js_code: code,
grant_type: 'authorization_code'
}
});
const { openid, session_key, errcode, errmsg } = wxResult.data;
if (errcode) {
// errcode 40029 常见于code过期,让前端重新wx.login
return res.status(500).json({ code: 500, msg: `微信登录失败: ${errmsg}` });
}
// 查用户表,不存在就创建
let user = await db.query('SELECT * FROM user WHERE openid = ?', [openid]);
if (!user) {
const result = await db.query(
'INSERT INTO user (openid, nickname, avatar) VALUES (?, ?, ?)',
[openid, userInfo.nickName, userInfo.avatarUrl]
);
user = { id: result.insertId, openid };
}
// 生成自己的token,有效期7天
const token = jwt.sign(
{ userId: user.id, openid },
config.jwtSecret,
{ expiresIn: '7d' }
);
res.json({ code: 0, data: { token, userId: user.id } });
});
几个要特别注意的点:
第一,session_key 一定不要直接返回给小程序端,也不要用它做前端登录态。它本质上是微信用来解密手机号、解密用户敏感信息的密钥,泄漏出去等于把用户手机号的解密能力暴露了。我在代码里只把 session_key 存在服务端,前端登录态完全靠自签发的 JWT 维持。
第二,每次 wx.login 换来的 code 只能用一次,而且有效期短。小程序端应该在自己的拦截器里统一处理登录,不要让每个页面各自调 wx.login,否则很容易出现“这个页面的 code 过期了”这类尴尬问题。
第三,JWT 的有效期按业务来定。纯商城类项目 7 天比较常见,但如果你做的是预约类、工具类小程序,用户可能几周才打开一次,建议把有效期拉到 30 天,同时做刷新机制。我接的一个预约项目就是 JWT 过期时间设太短,用户第二次打开就被踢出去,客户立刻来问“为什么每天都要重新登录”。
3.2 创建订单与商品校验
创建订单是后端业务逻辑最集中的地方,也是事务用的最频繁的地方。以一个最基础的订单创建接口为例,核心步骤如下:
javascript复制// 路由 POST /api/order/create
app.post('/api/order/create', async (req, res) => {
const { userId } = req.auth; // 登录中间件解析出来的用户
const { goodsList, remark } = req.body;
if (!Array.isArray(goodsList) || goodsList.length === 0) {
return res.status(400).json({ code: 400, msg: '商品列表不能为空' });
}
// 1. 生成业务订单号
const orderNo = generateOrderNo();
// 2. 查询商品,校验状态和库存,计算总价
let totalAmount = 0;
const goodsIds = goodsList.map(g => g.goodsId);
const goodsRows = await db.query('SELECT * FROM goods WHERE id IN (?)', [goodsIds]);
if (goodsRows.length !== goodsIds.length) {
return res.status(404).json({ code: 404, msg: '存在无效商品' });
}
for (const item of goodsList) {
const goods = goodsRows.find(g => g.id === item.goodsId);
if (goods.status !== 1) {
return res.status(400).json({ code: 400, msg: `商品 ${goods.name} 已下架` });
}
if (item.quantity <= 0 || item.quantity > 99) {
return res.status(400).json({ code: 400, msg: `商品 ${goods.name} 数量不合法` });
}
totalAmount += goods.price * item.quantity;
}
// 3. 落库:订单主表 + 明细表,必须在一个事务里
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const orderResult = await conn.query(
'INSERT INTO orders (order_no, user_id, total_amount) VALUES (?, ?, ?)',
[orderNo, userId, totalAmount]
);
const orderId = orderResult.insertId;
for (const item of goodsList) {
const goods = goodsRows.find(g => g.id === item.goodsId);
await conn.query(
'INSERT INTO order_item (order_id, goods_id, goods_name, goods_price, quantity) VALUES (?, ?, ?, ?, ?)',
[orderId, goods.id, goods.name, goods.price, item.quantity]
);
}
await conn.commit();
res.json({ code: 0, data: { orderNo, totalAmount } });
} catch (e) {
await conn.rollback();
throw e;
} finally {
conn.release();
}
});
generateOrderNo 的写法我要单独说。常见的错误是把订单号搞成“年月日时分秒 + 随机数”,这在业务量小的时候没问题,但遇到多个进程同时下单,有可能撞号,撞号了唯一索引插不进去,用户就会看到下单失败。我推荐用 “业务前缀 + 日期 + 毫秒时间戳 + 4位随机数” 的组合,保证单机不重复;如果以后要上多实例,提前在订单号里加一个机器实例编号位,这个习惯能在你扩大规模时省很多事。
还有个经验是接口层面做“防止重复提交”。用户在小程序里手快点了两下“提交订单”,后端收到了两个请求,就会生成两笔订单。解决思路有两种:前端按钮做 loading 状态禁点,后端对同一用户的“创建中订单”做兜底限制。比较稳妥的办法是后端在用户维度加一个 Redis 分布式锁,key 写成 user:order:create:{userId},有效期设 3 秒,请求进来先尝试加锁,加不上就说明上一次请求还没处理完,直接返回“请勿重复操作”。这个兜底逻辑看着小,但在真实场景里真的能救你于水火。
3.3 微信支付下单签名
小程序端拉起微信支付的流程是:后端调微信支付统一下单接口,拿到 prepay_id,然后用这个 prepay_id 生成小程序端需要的 paySign,返回给前端,前端再调 wx.requestPayment。
微信支付 API v3 的签名逻辑是所有新手最想吐槽的地方,但它其实就一个核心:你给微信发的每个请求,都要用你的商户私钥对“请求方法 + 请求路径 + 时间戳 + 随机串 + 请求体”拼接成的字符串做 RSA-SHA256 签名,然后把签名放进 Authorization 头里。
javascript复制const crypto = require('crypto');
function buildSign(method, url, timestamp, nonce, body) {
const message = `${method}\n${url}\n${timestamp}\n${nonce}\n${body}\n`;
const sign = crypto.createSign('RSA-SHA256');
sign.update(message);
return sign.sign(privateKey, 'base64');
}
调用统一下单接口的时候,路径是 https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi,请求体 JSON 里需要包含 appid、mchid、description、out_trade_no、notify_url、amount.total。
这里有个巨坑:amount.total 的单位是分,不是元。你从数据库查出来的是 99.00 元,传给微信的时候要 Math.round(total * 100),否则永远被微信拒绝。如果是 0.01 元的测试单,你传 0.01 给微信,微信会以为你要付 0.01 分,直接报参数错误。这种低级错误我很早以前也犯过,就因为这个单位问题折腾了一个下午。
统一下单成功之后,响应体里会有 prepay_id。接下来要生成小程序端用于拉起支付的 paySign:
javascript复制// data.result.prepay_id 是统一下单返回的
const payParams = {
timeStamp: String(Math.floor(Date.now() / 1000)),
nonceStr: randomString(32),
package: `prepay_id=${prepay_id}`,
signType: 'RSA'
};
const message = `${payParams.appId}\n${payParams.timeStamp}\n${payParams.nonceStr}\n${payParams.package}\n`;
payParams.paySign = crypto.createSign('RSA-SHA256')
.update(message)
.sign(privateKey, 'base64');
res.json({ code: 0, data: payParams });
注意这里拼接的内容和前面统一下单的签名拼接不一样,前面是“方法换行路径换行时间戳换行随机串换行请求体”,这里是“appId 换行 timeStamp 换行 nonceStr 换行 package 换行”,少一个都不行。微信的文档把这个写得很细,但我建议直接参考官方仓库里的 SDK,不要自己手写签名逻辑,尤其不要在网上抄一个不知道哪来的方法。官方 Node SDK 我用了很久,基本零问题。
3.4 支付回调与幂等处理
支付回调是整个项目里最容易出事故的地方。微信服务器会把支付结果以 POST 请求的形式发到你配置的 notify_url,你需要验签、解密、更新订单状态。这个过程有几个必须处理的点:
先说 rawBody 问题。用 Express 的时候,如果全局用了 express.json(),那么 req.body 已经是被解析过的 JSON 对象。但微信回调验签时,签名的原文是服务器收到的原始字符串,如果你把 req.body 转成字符串再验签,几乎必然失败。正确做法是单独关掉这个路由的 JSON 解析,或者捕获原始 body 字符串。
javascript复制// express.json() 要排除 notify 路由
app.post('/api/pay/notify', express.raw({ type: '*/*' }), async (req, res) => {
const body = req.body; // Buffer 原始内容
const signature = req.headers['wechatpay-signature'];
const timestamp = req.headers['wechatpay-timestamp'];
const nonce = req.headers['wechatpay-nonce'];
const serial = req.headers['wechatpay-serial'];
// 1. 验签
const valid = verifyWechatSign(body, signature, timestamp, nonce, serial);
if (!valid) {
console.error('回调验签失败');
return res.status(401).json({ code: 'FAIL', message: '验签失败' });
}
// 2. 请求体是加密的,用 APIv3 密钥解密
const payload = JSON.parse(body);
const plaintext = decryptAes256Gcm(payload.resource);
const notifyData = JSON.parse(plaintext);
// notifyData 包含 out_trade_no, transaction_id, trade_state 等
// 3. 业务处理
if (notifyData.trade_state === 'SUCCESS') {
await handlePaySuccess(notifyData);
}
// 4. 告诉微信“我收到了”
res.status(200).json({ code: 'SUCCESS', message: '成功' });
});
handlePaySuccess 里的幂等逻辑是重中之重的部分。回调可能因为网络问题被微信重发多次,如果你对同一个 out_trade_no 重复执行“把订单改成已支付”,本身问题不大,但如果你同时把用户余额加了一遍、把库存扣了一遍,那就会出现严重的资损。所以更新订单的时候,必须用“业务订单号 + 当前状态”作为条件,并且检查返回的影响行数。
javascript复制async function handlePaySuccess(notifyData) {
const { out_trade_no, transaction_id } = notifyData;
// 关键:WHERE 条件带 pay_status = 0,保证只更新一次
const result = await db.query(
'UPDATE orders SET pay_status = 1, transaction_id = ?, pay_time = NOW() WHERE order_no = ? AND pay_status = 0',
[transaction_id, out_trade_no]
);
if (result.affectedRows === 1) {
// 只有第一次更新成功才做后续操作:减库存、发通知等
await deductStock(out_trade_no);
await notifyUserPaySuccess(out_trade_no);
}
}
这个 affectedRows === 1 的判断就是幂等保护。如果订单已经被处理过,第二次回调进来时条件 pay_status = 0 不成立,更新影响行数为 0,后续的减库存和通知就不会重复执行。这个设计一定要有,否则上线后等着你的就是“微信把同一个回调发了三次,用户库存被扣成负数”这种事故。
4. 联调测试与部署上线
4.1 本地联调踩坑记录
本地联调阶段,我建议按“登录 -> 创建订单 -> 支付 -> 回调 -> 查订单”这条链路走,每一步都要确认数据落库正确,而不是只在接口层面看到返回成功就完事。
登录接口联调的时候,最常遇到的问题是 code 无效。这通常是因为小程序开发工具里的 AppID 和后端配置的 AppID 不是同一个。比如你用的是测试号,后端却用了正式小程序的 secret,两个对不上,自然换不到 openid。我建议所有环境变量集中放一个 .env 文件,调试前先核对 AppID、Secret、商户号、证书序列号,这一个检查能帮你省掉很多无效排查。
支付联调的时候有一个痛点:微信支付要求小程序必须是已认证的账号才能开通支付权限,而且在开发阶段,你在小程序开发者工具里看到的模拟支付环境和真实环境机制不同,很多坑得真机测试才能暴露。我的做法是在本地准备一个 notify_url 的穿透工具,把微信回调打到本地开发环境,这样日志实时可见,调试效率最高。
notify_url 必须是一个公网可以访问的 HTTPS 地址,这是微信支付的硬性要求。没有 HTTPS 的话微信会直接拒绝回调。开发阶段的临时方案是用内网穿透类的工具,或者干脆部署到测试服务器上调试。上线时必须用正式域名,这个大家一定提前准备好。
4.2 HTTPS 与服务器部署要点
接单交付通常要求你有能力帮客户把项目跑起来,所以服务器部署这步几乎逃不掉。我的标准部署方案是:
- 云服务器:系统选 Ubuntu 22.04,入门配置即可。
- Nginx:做反向代理,把 443 端口的请求转发给 Node 服务。
- PM2:管理 Node 进程,服务器重启后自动拉起。
- HTTPS 证书:用免费的 Let's Encrypt 或者云厂商的免费证书。微信小程序正式环境要求所有请求域名都必须是 HTTPS,且域名需要在小程序后台配置到 request 合法域名和 socket 合法域名里。
Nginx 配置的核心就两点:一是反向代理到本地端口,二是把请求体原样传给后端。支付回调如果被 Nginx 层做了 body 改写,可能导致验签失败,所以不要开任何 body 修改相关的功能。
nginx复制server {
listen 443 ssl;
server_name api.yourdomain.com;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
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;
}
}
部署好后,别急着说“上线了”,先做一个冒烟测试:小程序端提交一单 0.01 元的测试订单,走完全流程,确认订单状态从“待支付”变成“已支付”,再去数据库查一下 pay_time 和 transaction_id 是否正确写入。这个测试做完,你才有底气告诉客户项目已经可以用了。
5. 兼职接单的交付心得与高频问题排查
5.1 接单前的需求确认清单
给想靠这个方向接单的朋友一份我踩过坑换来的清单,接单沟通阶段直接对一遍:
- 确认小程序主体:客户注册小程序了吗?是个人主体还是企业主体?支付功能必须企业主体才能开通。
- 确认微信支付商户号:是客户自己申请的,还是需要你帮忙申请?商户号申请需要营业执照,时间周期通常 1-3 个工作日,要提前规划工期。
- 确认后端部署方式:客户有没有服务器?没有的话是否接受你帮他买一台,或者塞一组托管费用?
- 确认小程序端谁写:如果只接后端,前端联调需要前端开发配合;如果前后端都你写,工期要翻倍,价格也要翻倍。
- 确认订单流程细节:是否需要退款功能?是否需要自动取消超时未支付订单?是否需要发货物流信息?这些哪怕客户刚开始说“先不要”,你也要在合同或需求确认单里记录下来,免得后续扯皮。
这些事项看着琐碎,但每一条都是真实项目里被客户问过、扯过的问题。尤其是“退款”,很多新手觉得退款就一行 UPDATE,但微信支付的退款接口需要双向证书、需要校验退款金额不能超过原支付金额、需要处理退款回调,一套下来就是一天的活。客户如果在需求阶段不确认,交付阶段提出来,你根本没有报价空间。
5.2 高频问题排查速查表
最后整理一个订单后端最常见的排查表,都是我实际项目里遇到过的:
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 用户登录不了,提示 code 无效 | AppID/Secret 配置错误,或 code 被重复使用 | 先核对 .env 配置,再确认前端只调一次 wx.login |
| 创建订单失败,提示商品无效 | 商品表里没有对应记录,或商品状态不是上架 | 查 goods 表,确认 goods_id 和 status |
| 调起微信支付没反应 | 后端生成的 paySign 拼接过期或参数不对 | 对比微信官方示例,重点检查拼接顺序和单位 |
| 支付成功但订单状态没变 | 回调没到后端,或回调里更新条件不对 | 看 Nginx 日志和后端日志,确认 notify_url 是否可达 |
| 同一个订单被重复处理 | 支付回调幂等没做好 | 检查更新语句是否带 pay_status = 0,是否判断 affectedRows |
| 微信推送提示证书序列号无效 | API 证书序列号和商户证书序列号混淆 | APIv3 用商户证书的序列号,不是平台证书序列号 |
| 小程序真机请求失败 | 域名没有加白名单,或没走 HTTPS | 在小程序后台配置 request 合法域名,确保证书有效 |
我的亲身体会是,订单后端 80% 的问题都出在签名、单位、状态这三个环节。签名错了微信直接拒绝,单位错了支付金额不对,状态错了对账对不上。你把这三条捋顺,整个项目就稳了。
5.3 一套代码多接几个单的思路
这个项目做完之后,别急着把它丢到 GitHub 吃灰。我把整个项目的骨架单独抽成了一个模板仓库,商品表、订单表、用户表、支付回调、登录接口都是现成的。后面再接新单子,只需要根据需求改商品字段、改订单展示字段、替换小程序的 UI 样式,后端核心逻辑基本不动。
有一次接一个同城跑腿的单子,需求方要的其实不是商品订单,而是配送订单,核心还是那一套:下单、支付、接单、完成。我把模板里的商品换成“配送线路”,把“发货”改成“骑手接单”,两天就完成了后端交付。价格没少收,但我的实际工时少了一大半,这种效率提升就是前期沉淀模板的价值。
如果你打算长期做这块,不妨再抽一层管理后台的通用接口,包括订单列表、发货操作、手动取消订单、退款操作。客户那里最常提的需求也就是这几个,有了通用接口,你后续接单的交付速度会越来越快。
