2026/10/30 5:28:51

电商补单系统源码设计:幂等、审批、审计与对账链路

电商补单系统源码设计:幂等、审批、审计与对账链路 简介面向希望研究电商补单流程或进行二次开发的开发者这套源码围绕模拟浏览、加购、下单与支付等环节提供订单生成、支付模拟、任务管理等模块的前后端实现思路。压缩包共418个文件、约609KB以gif、jpg、png等界面素材和html、js、css前端页面为主同时包含asp、aspx、php等后端脚本及mdb数据库、配置文件另可见svn版本痕迹便于理解站点结构与页面组织。已有6441人浏览学习。读者可据此梳理补单网站从用户界面、后台管理到数据存储的基本链路参考任务状态跟踪与接口调用方式用于源码阅读、功能拆解和本地测试实际使用仍需关注平台规则与源码安全避免违规操作。1. 补单系统源码先看清边界它补的是「事后订单」不是重下一单大促结束第三天客服后台会集中冒出一种工单用户说钱付了、订单号也有仓库却查不到发货记录或者包裹到了发现少件、破损需要补寄一件。这类问题在正向下单入口里根本走不通——款已经收过、库存已经扣过、优惠券已经核销过你不可能让用户再下一单。补单系统源码要解决的就是这段“事后补救”在真实交易已经发生的前提下用一条受控、可审批、可倒查的链路把漏掉的订单补录进业务库把该补的货补发出去。这套源码适合三类人电商或零售后台的研发手上有一堆历史脏单要清洗做 ERP、进销存二次开发的同学客户提“补录订单”提了半年还有接外包单的独立开发者需要一套能直接改业务流程的骨架。它不适合拿来做营销玩法链路里最重的三块是幂等、审批和审计目的只有一个——让补出去的每一单都能对得上账、找得到人。2. 五张表搭起补单主链路主表、明细、审批、幂等、审计怎么拆拿到一套补单程序先别看 Controller先看建表语句。表结构基本决定了这套系统能跑多远。很多市面上的补单源码只有一张order表加一个status字段改改状态就算“补单完成”这种结构上线两周就会出问题原订单的退款流程和补单流程打架对账系统分不清这笔钱是补录还是新增客服也说不清是谁在什么时候把状态改成“已发货”的。我一般的判断标准是这套补单网站源码有没有把「补单本身」当成一个独立业务对象。合格的拆法是五张表——补单主表、补单明细表、审批流水表、幂等记录表、操作审计表。主表存这一单的来龙去脉和当前状态明细表存 SKU 和金额分摊审批流水存每一次状态跃迁的审批人幂等表挡住重复提交审计表存改前改后的快照。2.1 为什么不直接改原订单表最省事的做法是拿到原订单号直接UPDATE order SET status已支付三行代码搞定。翻车点在于原订单表的每一次状态变更都挂着一堆消费者——支付回调、库存回补、积分发放、发票开具、对账中心。你手工把状态推过去等于绕过了所有旁路逻辑对账中心会把这笔单当成“支付成功但无支付流水”日终平账时直接爆差异。正确的思路是独立流水补单表只记录“我在什么原因下、给哪张原单、补了什么”真正的执行动作通过调用领域服务完成库存服务、支付网关、物流下单。主表和原订单之间只保留一条弱关联source_order_no对账时用这条关联去做勾稽而不是让两张表的状态互相污染。2.2 主表与明细表的字段设计主表里有几个字段是绕不过去的字段类型作用与取值说明supplement_novarchar(32)补单号独立于原订单号建议带业务前缀和日期人工排查时能一眼定位biz_typetinyint1 漏单补录、2 破损补发、3 换货补寄、4 线下补录不同取值走不同审批阈值source_order_novarchar(32)原始订单号允许空串但禁止 NULL线下补录场景可能是空的idempotent_keychar(32)幂等键由业务类型、原单号、明细指纹拼 md5唯一索引total_amountbigint金额一律用分存储禁用 decimal 和 doublestatustinyint0 草稿、10 待审、20 已审、30 执行中、40 成功、50 失败、90 关闭versionint乐观锁版本号状态跃迁必须带它做 CAS金额用分存储这件事被讨论烂了但依然有人踩。优惠分摊到明细行时100.00打八折再拆三件按元算必然是26.666...落到库里就是无限小数。用bigint存分分摊用整除最后一行兜尾差账永远平。2.3 建表 SQL 与索引取舍CREATE TABLE biz_supplement_order ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, supplement_no VARCHAR(32) NOT NULL COMMENT 补单号 SPyyyyMMdd6位序列, biz_type TINYINT NOT NULL COMMENT 1漏单补录 2破损补发 3换货补寄 4线下补录, source_order_no VARCHAR(32) NOT NULL DEFAULT COMMENT 原始订单号, idempotent_key CHAR(32) NOT NULL COMMENT 幂等键, member_id BIGINT UNSIGNED NOT NULL COMMENT 会员ID, total_amount BIGINT NOT NULL DEFAULT 0 COMMENT 补单金额单位分, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 10待审 20已审 30执行中 40成功 50失败 90关闭, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, apply_user_id BIGINT UNSIGNED NOT NULL COMMENT 申请人, audit_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 审核人0 表示未审, fail_reason VARCHAR(255) NOT NULL DEFAULT COMMENT 执行失败原因, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY (id), UNIQUE KEY uk_supplement_no (supplement_no), UNIQUE KEY uk_idempotent (idempotent_key), KEY idx_source_order (source_order_no), KEY idx_status_ctime (status, created_at), KEY idx_member (member_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT补单主表; CREATE TABLE biz_supplement_item ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, supplement_no VARCHAR(32) NOT NULL COMMENT 关联主表补单号, sku_id BIGINT UNSIGNED NOT NULL COMMENT 商品SKU, quantity INT NOT NULL COMMENT 补单数量必须大于0, unit_amount BIGINT NOT NULL COMMENT 单价分, discount_amount BIGINT NOT NULL DEFAULT 0 COMMENT 分摊优惠分, warehouse_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 指定发货仓0 表示由履约侧路由, PRIMARY KEY (id), KEY idx_supplement_no (supplement_no), KEY idx_sku (sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT补单明细表;这两段 SQL 里有两个索引值得单独说。uk_idempotent是最后一道幂等防线业务层用 Redis 占位之后如果缓存失效或者并发穿透唯一索引会直接把第二条插入打回DuplicateKeyException捕获后查出已有单号返回即可用户感知不到。idx_status_ctime服务于两个高频查询后台列表按状态筛选分页、定时任务扫“执行中超时未终态”的单子缺了它表过百万之后扫表会把 DB 拖垮。审批流水表和审计表结构相对简单但字段语义不能省。审批流水至少要有supplement_no、from_status、to_status、operator_id、operator_role、opinion、created_at审计表要有before_snapshot、after_snapshot两个json字段以及trace_id。这套结构看起来啰嗦等你被财务拉进群里问“这单为什么补了两遍”时就知道它是唯一的后悔药。3. 从申请到执行幂等键、状态机 CAS 与库存回补的代码骨架表定完了接下来是最容易写出玄学 bug 的一段提交、审核、执行三段链路。绝大多数补单程序出问题不在业务逻辑复杂而在并发和事务边界——两个客服同时点提交、审核通过后调库存服务超时、执行失败没有回滚标记这三件事能覆盖八成的线上事故。下面按顺序拆。3.1 幂等键怎么拼才有意义幂等键不是 UUID。用 UUID 做幂等键等于没做因为每次点击生成的键都不一样唯一索引也挡不住重复。有效的幂等键必须由业务要素拼出来保证“同样的业务意图”一定得到同一个字符串public final class IdempotentKeys { /** * 业务要素拼幂等键业务类型 原单号 明细指纹 * 明细必须先排序再拼否则 [A,B] 和 [B,A] 会算出两个不同的键 */ public static String build(int bizType, String sourceOrderNo, ListItemCmd items) { String fingerprint items.stream() .sorted(Comparator.comparing(ItemCmd::getSkuId)) .map(i - i.getSkuId() x i.getQuantity()) .collect(Collectors.joining(|)); String raw bizType # sourceOrderNo # fingerprint; return DigestUtils.md5Hex(raw); // 32 位定长正好塞进 char(32) } }这段代码的关键在sorted。明细顺序不稳定是分布式系统里的经典坑前端购物车顺序、JSON 反序列化顺序、幂等键拼接顺序三者不一致同一笔业务会被算成两个键唯一索引形同虚设。排序之后{skuA×2, skuB×1}无论怎么传都得到同一个 md5。还有一种边界情况要处理换货补寄场景里同一个原单号可能合法地补两次第一次补寄丢失第二次重新补寄。这时候不能只用原单号做键得把biz_type和明细指纹一起放进去同时允许前端透传一个request_id作为极端情况下的第二重区分。我一般会在幂等表里同时存idempotent_key和request_id两个唯一索引前者挡业务重复后者挡网络重试。3.2 提交与审核的状态机推进状态跃迁必须用 CAS不能先select再update。两个审核员同时点通过先查后改的写法会让两张审批流水都写进去状态却只推进一次对账时流水条数和状态对不上。Service public class SupplementApproveService { Transactional(rollbackFor Exception.class) public void approve(String supplementNo, long auditorId, int expectVersion, String opinion) { // 1. CAS 推进 10(待审) - 20(已审)版本号不匹配说明被别人抢先改了 int rows supplementMapper.casUpdateStatus( supplementNo, Status.PENDING_AUDIT, Status.AUDITED, expectVersion, auditorId); if (rows 0) { throw new StateConflictException(补单状态已变更请刷新后重试 supplementNo); } // 2. 同步写审批流水和状态变更同事务 approvalMapper.insert(ApprovalRecord.of(supplementNo, Status.PENDING_AUDIT, Status.AUDITED, auditorId, opinion)); // 3. 写本地消息表事务提交后由调度器异步投递给库存/履约服务 outboxMapper.insert(OutboxMessage.of(SUPPLEMENT_EXECUTE, supplementNo)); } }三个动作写在同一个本地事务里是因为它们必须同生共死状态推进了但审批流水没落库等于没有责任人消息表没写单子就永远卡在“已审”没人执行。真正的库存调用放在事务外由调度器扫outbox表投递投递成功后再把状态从 20 推到 40失败则推到 50 并记录fail_reason。casUpdateStatus对应的 SQL 只要一行但版本号判断不能省UPDATE biz_supplement_order SET status #{to}, audit_user_id #{auditorId}, version version 1 WHERE supplement_no #{no} AND status #{from} AND version #{expectVersion};受影响行数为 0 就是冲突信号直接抛业务异常让前端刷新。别用SELECT ... FOR UPDATE硬扛补单是低频操作悲观锁会把这行行锁拖到整个调用链结束遇到慢调用就变成事故。3.3 库存回补与金额分摊的执行细节执行阶段最容易出错的是“补发的货从哪个仓出”。漏单补录一般走原订单的发货仓破损补发则要看当前库存分布常见做法是把warehouse_id留 0交给履约服务按路由规则选仓。这个字段别急着在补单侧写死写死之后跨仓调拨的场景就没法支持了。金额分摊有一段必写的兜底逻辑/** 把总优惠按明细行金额占比分摊到分尾差落在最后一行 */ public static void allocateDiscount(ListItem items, long totalDiscount) { long allocated 0L; for (int i 0; i items.size(); i) { Item it items.get(i); long share; if (i items.size() - 1) { share totalDiscount - allocated; // 最后一行吃尾差 } else { share BigInteger.valueOf(totalDiscount) .multiply(BigInteger.valueOf(it.getUnitAmount() * it.getQuantity())) .divide(BigInteger.valueOf(sumAmount(items))) .longValue(); } it.setDiscountAmount(share); allocated share; } }用BigInteger做中间运算再longValue()是为了避免long * long溢出。单笔订单金额不大时看不出差别等你碰上批量补录几万块的 To B 单子unitAmount * quantity * totalDiscount瞬间就超Long.MAX_VALUE分摊结果变成负数财务那边直接炸。4. 对账与留痕让每一笔补单都能倒查到原始订单补单系统上线之后研发最常被问的一句话是“这笔补单能不能给我看下是怎么来的”。如果答不上来这套系统就是黑匣子。对账和审计不是附加功能是这套补单网站源码能不能长期用下去的分水岭。4.1 补单流水与四类原始数据的勾稽关系一笔成功的补单理论上要在四个地方留下痕迹补单主表、库存流水出库一条、支付或应收流水补录场景可能没有、物流单补发场景必有一条。四者对不上就是差异差异要能自动发现而不是靠人肉比。勾稽项左表右表差异含义库存出库biz_supplement_order.status40stock_flow.flow_typeSUPPLEMENT_OUT状态成功但没扣库存多为调用超时后状态被误推物流单补单主表logistics_order.biz_no补发单没生成运单履约侧丢消息金额total_amount明细unit_amount*quantity - discount_amount之和分摊代码有 bug 或手工改库原单关联source_order_noorder_main.order_no补录单挂了一个不存在的原单号这张表建议直接落成一个每日跑的差异任务扫前一天的数据差异写进biz_supplement_diff表并按条数告警。别等到月结才查那时候数据量上来了改都改不动。4.2 对账 SQL 怎么写才不拖库差异扫描的 SQL 有个共性不能全表扫必须带时间范围走索引。下面这段查“补单成功但库存流水缺失”是上线后跑得最多的一条-- 扫描指定时间窗口内状态为成功的补单左连库存流水找缺失 SELECT s.supplement_no, s.biz_type, s.source_order_no, s.total_amount, s.audit_user_id, s.updated_at FROM biz_supplement_order s LEFT JOIN stock_flow f ON f.biz_no s.supplement_no AND f.flow_type SUPPLEMENT_OUT WHERE s.status 40 AND s.updated_at #{begin} AND s.updated_at #{end} AND f.id IS NULL ORDER BY s.updated_at LIMIT 500;updated_at上有idx_status_ctime的前缀吗没有——索引是(status, created_at)。实际跑批时我会额外建一个idx_status_utime (status, updated_at)因为对账永远按“最近变更”而不是“创建时间”扫用错索引会让这条 SQL 在千万级表上跑十几分钟。加LIMIT 500是防止一次性查出几万条把应用内存打满配合游标翻页即可。发现差异后的处理原则是“不自动修”。库存没扣的补单自动补扣可能造成二次扣减正确做法是生成差异工单由运营确认后走一次人工补偿接口补偿动作本身也写审计日志。自动化在这里是风险放大器。4.3 审计日志要记到什么粒度审计表的字段设计有个简单判断标准拿着这条日志不看其他任何表能不能还原出“谁在什么时候把哪个字段从什么改成了什么”。做不到就说明粒度不够。{ supplement_no: SP20240518000031, action: APPROVE, operator_id: 10086, operator_role: FINANCE_AUDITOR, before: {status: 10, total_amount: 25800}, after: {status: 20, total_amount: 25800, audit_user_id: 10086}, trace_id: a1b2c3d4e5f6, ip: 10.0.0.12, created_at: 2024-05-18T14:22:31.41208:00 }trace_id这一列很多人会漏。漏了之后一次提交跨了网关、补单服务、库存服务三个系统出问题只能靠时间戳猜。把网关生成的 trace_id 一路透传下来日志平台里一搜就是完整链路比翻三台机器的日志快得多。另外before和after别只存变化的字段要存关键字段的完整快照。补单这种低频高风险的业务存全量快照带来的存储成本几乎可以忽略但排查时的价值极高。5. 上线后最容易翻车的五处补单系统的排查清单下面五条都是实打实踩过的按“现象 → 原因 → 解决”写遇到对应症状可以直接对照。第一同一笔漏单补了两遍库存多扣一件。现象是同一source_order_no出现两条成功补单客服说只点了一次。原因是幂等只做了前端按钮置灰接口层没有唯一约束用户网络慢重复点击、或者前端重试机制触发两条请求并发进来都查不到已有记录。解决方式是把幂等做成三层前端置灰只是体验Redissetnx加过期时间挡住短时间重复数据库唯一索引兜住最终一致性三层缺一不可。第二补单执行卡在“执行中”再也不动了。现象是后台列表里一堆状态 30 的单子updated_at停在几天前。原因是审核通过后同步调用库存服务某次调用超时抛异常事务回滚了状态但消息已经发出去了或者消息发出后消费端异常没有重试。解决方式是把同步调用改成 outbox 表加定时补偿同时给状态加超时判定status30 且 updated_at now()-10min的单子自动进入待排查队列由补偿任务重新投递投递三次仍失败则置为 50 并告警。第三补单金额和明细合计差几分钱。现象是财务对账时发现主表金额比明细累加多 0.03。原因是优惠分摊用decimal按比例算完各自四舍五入三行各进一位就多出三分。解决方式是全部改bigint存分分摊用整除尾差强制落到金额最大的那一行并在明细表加一个tail_diff字段显式记录这笔尾差对账时把它算进合计。第四客服能自己提自己审。现象是某段时间补单量异常上涨查出多个单子的申请人和审核人是同一个账号。原因是审批流只校验了状态没校验角色互斥。解决方式是在审核接口入口加两条硬校验apply_user_id ! auditor_id且审核人必须具备SUPPLEMENT_AUDIT权限位金额超过阈值时强制两级审批第二级必须是财务角色。这两条要写在服务层而不是前端前端校验只是提示。第五改了一条数据审计表里查不到。现象是运营手工修正了一笔补单的发货仓事后追责时审计表空白。原因是修数走的是 DBA 直连改库绕过了应用层。解决方式是收掉生产库的写权限所有修数必须走后台的“数据修正”入口该入口强制写审计日志同时对核心表加 binlog 监听应用层没记录的变更由监听器补一条SOURCEBINLOG的日志两条路互为兜底。6. 灰度前的一次全链路验证用影子单把状态机跑穿代码写完不等于能上线。补单系统的特点是平时量极小、一出事就是资金和库存问题所以验证不能靠“跑一单看看”。我一般会造一批影子单把状态机的每条边都走一遍包括那些正常业务永远走不到的分支。具体做法是写一个造数脚本按biz_type × status的组合生成用例重点覆盖四条异常路径待审状态被重复审核、已审状态执行超时后补偿重投、执行失败后人工重推、关闭状态被误操作唤醒。每条用例断言三件事——状态终态是否符合预期、审批流水条数是否与状态跃迁次数一致、审计表是否有对应记录。这三条断言比任何单元测试都管用因为它们验证的是“数据能不能倒查”而这正是补单系统存在的意义。跑影子单的时候有个技巧值得单独拎出来给测试单的补单号加一个固定前缀比如SPT然后在所有下游服务库存、物流、对账里配置白名单遇到这个前缀的单一律走沙箱通道、不产生真实出库。这样一套环境可以从头跑到尾不用每次清库。等验证通过把前缀去掉同一套用例直接当回归脚本用。还有一个我后来强制加进流程的动作每次发版前手工跑一遍“补单号生成器”的并发测试200 个线程同时申请补单号检查有没有重复。补单号看着简单SP yyyyMMdd 6位序列但序列如果用数据库自增再拼日期跨天那一刻很容易撞号用 RedisINCR又要处理 key 过期重建的竞态。这个坑我在两个项目里都见过从那以后每次涉及单号生成的改动我都会强制走一遍并发用例宁可多花二十分钟也不想半夜被“主键冲突”叫起来。希望帮到你。本文还有配套的精品资源点击获取