AI 写完订单后台,退货按钮点两次会怎样:Go + MySQL 幂等检查

📅 2026/7/31 19:06:40
AI 写完订单后台,退货按钮点两次会怎样:Go + MySQL 幂等检查
AI 或代码生成器把订单、商品、库存页面做出来只能说明 CRUD 有了。退货按钮点两次库存会不会回滚两次退款回调晚到订单状态会不会倒退运营拿旧 token 直接打接口能不能绕过页面按钮。这些才是进销存后台上线前最该先验的地方。这篇不讲完整进销存系统怎么从零搭。我只拆一个很窄的动作退货。因为它同时碰到状态机、库存流水、幂等键、RBAC 权限和审计日志。页面看起来再完整如果这几个点没有验后面补起来很难受。退货不是一个 update很多后台初版会把退货写成这样UPDATE order_item SET refund_status refunded WHERE id 1001; UPDATE product_stock SET available_qty available_qty 2 WHERE sku_id 2001;这段 SQL 看着没问题实际少了几层判断订单是不是当前租户的订单是否已经发货、已退货或已取消同一个退货请求是不是重放过库存流水有没有唯一业务单号谁发起退货、走的是哪个权限码后面做导出、对账、补偿任务时能不能查回完整链路。AI 很容易根据表名生成“退货接口”但它不知道你的业务里退货能不能撤销、部分退货怎么算、支付退款和库存释放谁先谁后。这里不能靠页面验收要靠数据约束和接口重放验收。表结构先把幂等和租户写进去我会先把退货请求和库存流水分开。订单状态是一件事库存动作是另一件事。库存流水表里必须有业务动作、业务单号、租户和幂等键。CREATE TABLE inventory_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, order_id BIGINT NOT NULL, action VARCHAR(32) NOT NULL COMMENT sale/refund/reversal/adjust, qty INT NOT NULL, idempotency_key VARCHAR(96) NOT NULL, operator_id BIGINT NOT NULL, remark VARCHAR(255) DEFAULT , created_at DATETIME NOT NULL, UNIQUE KEY uk_tenant_idem (tenant_id, idempotency_key), KEY idx_tenant_sku_time (tenant_id, sku_id, created_at), KEY idx_order_action (tenant_id, order_id, action) );uk_tenant_idem是兜底不是装饰。接口层可以先查 Redis 或幂等表但数据库唯一键必须在最后拦住重复插入。否则网关重试、前端连点、定时补偿任务重复跑都可能把库存加两遍。订单明细也要带状态条件不要只按 id 更新UPDATE order_item SET refund_status refunded, updated_at NOW() WHERE tenant_id ? AND id ? AND refund_status none AND pay_status paid;这条 SQL 的返回行数很重要。影响 1 行才说明状态从none变到了refunded。影响 0 行不一定是数据库异常也可能是重复退货、跨租户、订单状态不允许退。接口要把这些分开记录不能统一回“操作失败”。Go 接口里先验权限再验状态退货接口的顺序不要写反。先验证登录和权限再拿租户上下文再进入业务事务。否则你会在日志里看到一堆状态错误却漏掉真正的问题没有退货权限的人已经打到了业务层。func RefundOrder(ctx context.Context, req RefundReq) error { user : auth.UserFromCtx(ctx) if user nil { return ErrUnauthorized } if !rbac.HasPermission(ctx, user.ID, inventory:refund) { return ErrForbidden } tenantID : user.TenantID idemKey : fmt.Sprintf(refund:%d:%d:%s, tenantID, req.OrderID, req.RequestNo) return dao.Transaction(ctx, func(ctx context.Context) error { item, err : dao.OrderItem. Ctx(ctx). Where(tenant_id, tenantID). Where(order_id, req.OrderID). Where(refund_status, none). ForUpdate(). One() if err ! nil { return err } if item.IsEmpty() { return ErrRefundState } _, err dao.InventoryFlow.Ctx(ctx).Insert(g.Map{ tenant_id: tenantID, sku_id: req.SkuID, order_id: req.OrderID, action: refund, qty: req.Qty, idempotency_key: idemKey, operator_id: user.ID, created_at: gtime.Now(), }) if isDuplicateKey(err) { return ErrDuplicateRefund } if err ! nil { return err } _, err dao.ProductStock.Ctx(ctx). Where(tenant_id, tenantID). Where(sku_id, req.SkuID). Increment(available_qty, req.Qty) if err ! nil { return err } _, err dao.OrderItem.Ctx(ctx). Where(tenant_id, tenantID). Where(order_id, req.OrderID). Where(refund_status, none). Data(g.Map{refund_status: refunded, updated_at: gtime.Now()}). Update() return err }) }这里的重点不是把代码照搬到项目里而是顺序权限、租户、状态锁、流水唯一键、库存释放、订单状态更新。少任何一个退货按钮都可能在正常测试里过关在重复请求或跨租户场景里翻车。用两次 curl 重放不要只看 200最小验收可以很土。拿同一个请求打两次看第二次是不是被幂等逻辑挡住。curl -i -X POST https://api.example.com/admin/inventory/refund \ -H Authorization: Bearer admin-token \ -H Content-Type: application/json \ -d { order_id: 10001, sku_id: 20001, qty: 2, request_no: refund-10001-001 } curl -i -X POST https://api.example.com/admin/inventory/refund \ -H Authorization: Bearer admin-token \ -H Content-Type: application/json \ -d { order_id: 10001, sku_id: 20001, qty: 2, request_no: refund-10001-001 }我希望看到的结果类似这样HTTP/1.1 200 OK {code:0,message:ok,flow_id:88123} HTTP/1.1 409 Conflict {code:40901,message:duplicate refund request}再查库存和流水SELECT available_qty FROM product_stock WHERE tenant_id 10 AND sku_id 20001; SELECT action, qty, idempotency_key, COUNT(*) AS cnt FROM inventory_flow WHERE tenant_id 10 AND order_id 10001 GROUP BY action, qty, idempotency_key;第二条查询里cnt应该是 1。库存只释放一次。这个检查比“页面提示退货成功”更可靠。跨租户和低权限账号要单独测进销存后台很容易把“能不能看到菜单”和“能不能打接口”混在一起。前端隐藏退货按钮只能减少误点不能当权限边界。接口层必须独立拦截。# 没登录 curl -i -X POST https://api.example.com/admin/inventory/refund # 期望401 # 登录了但没有退货权限 curl -i -X POST https://api.example.com/admin/inventory/refund \ -H Authorization: Bearer viewer-token # 期望403 # A 租户 token 操作 B 租户订单 curl -i -X POST https://api.example.com/admin/inventory/refund \ -H Authorization: Bearer tenant-a-token \ -H Content-Type: application/json \ -d {order_id:90001,sku_id:20001,qty:1,request_no:x-1} # 期望404 或 403但不能改到 B 租户库存后台权限最好拆到动作级。inventory:view、inventory:export、inventory:refund、inventory:reversal不能全塞进一个“库存管理”权限里。退货和冲销都在改库存但风险不同审批、日志和授权也应该不同。审计日志要能回答“谁把库存放回去了”退货出问题时运营不会只问“接口成功了吗”。他们会问哪个人、哪个租户、哪张订单、释放了哪个 SKU、是不是重复请求、当时的权限码是什么。日志至少要这样记录{ event: inventory.refund, tenant_id: 10, operator_id: 501, permission: inventory:refund, order_id: 10001, sku_id: 20001, qty: 2, idempotency_key: refund:10:10001:refund-10001-001, result: success, flow_id: 88123, request_id: req-20260730-0001 }失败也要记尤其是duplicate、forbidden、state_not_allowed。这些日志会决定后面能不能查账。如果只记“按钮点击成功”排查时基本等于没有日志。代码生成器适合生成骨架不适合替你决定业务状态我在看 XYGo Admin 的代码生成器时更愿意把它当后台骨架样本页面、权限和字段同步能先立起来但像退货幂等、库存释放和租户隔离这种业务动作生成后还要补验收。可以对照它的server/internal/logic/gencodes/generate.go、server/internal/logic/gencodes/sync_fields.go和server/internal/middleware/admin_permission.go看生成器、字段同步和接口权限分别落在哪一层。源码入口放这里方便对照路径GitHub 仓库。这里要说清楚边界它不是现成进销存 SaaS也不会替某个行业直接决定退货、冲销、支付退款和云打印规则。这个判断也能和仓库里的 Issue #9 对上用户问的是 SaaS、多租户、库销存、支付配置和云打印机需求真正难点就在业务动作和边界不是多生成几张表。适合参考的是后台底座怎么组织 RBAC、CRUD 生成器和字段同步业务动作还要按自己的订单状态和库存模型补。上线前我会保留这张检查表检查项期望结果常见坏味道重复退货请求第二次返回 duplicate库存不变两次都 200库存加两次低权限账号返回 403前端没按钮但接口能打跨租户订单查不到或 403只按 order_id 更新状态回退已退货不能再退只改 refund_status没有状态条件库存流水同一 idempotency_key 只有一条只有库存余额没有流水审计日志有 tenant、operator、permission、request_id只记“操作成功”导出对账可按订单和 SKU 查回流水导出只查余额表如果一个 AI 生成的后台连这张表都过不了我不会急着补页面细节。页面错了通常还能改库存和退货链路错了后面就是对账、退款和客服一起补锅。结尾留个问题你们验收进销存后台时会先看页面流程还是先用重复请求把库存接口打一遍