进销存后台别急着上线,先重放一次退货请求

Cursor 或 Agent 把商品、订单、库存流水的 CRUD 写出来以后,后台看起来已经像回事了。真正要命的地方不在列表页,而在退货、冲销、库存释放这种"动作"。同一个请求被点两次,库存会不会加两次?这个问题比页面好不好看更早暴露系统边界。

我现在看 AI 生成的后台模块,会先做一个很土的检查:把退货请求原样重放两次。第一次应该成功,第二次不能再次改库存。它可以返回 409,也可以返回同一个处理结果,但不能悄悄再插一条流水、再释放一次库存。

这个检查不复杂,麻烦的是很多后台一开始没把它当成接口契约,只把退货当成普通 update。

先把"动作"从 CRUD 里拆出来

商品、仓库、订单这些表可以生成。字段、列表、搜索、详情、编辑,大部分都属于低风险重复劳动。库存动作不一样。

拿退货举例,它至少牵到几件事:

  • 订单状态能不能退;
  • 退的是整单还是某个 SKU;
  • 库存释放几件;
  • 退款、发票、云打印或出库单是否已经走过;
  • 同一个请求重放时按重复处理,还是按幂等成功处理;
  • 当前操作者有没有 inventory:refund 这种动作权限。

如果这些规则没有写出来,AI 生成的代码很可能只会做两件事:插入一条库存流水,更新一个库存数量。能跑,但不等于能上线。

一个最小的库存流水表

我会先让退货动作落到一张独立流水表里,而不是只改库存余额。

sql 复制代码
create table inventory_stock (
  tenant_id bigint not null,
  sku_id bigint not null,
  qty int not null,
  primary key (tenant_id, sku_id)
);

create table inventory_flow (
  id bigint primary key auto_increment,
  tenant_id bigint not null,
  order_id bigint not null,
  sku_id bigint not null,
  action varchar(32) not null,
  qty int not null,
  idempotency_key varchar(128) not null,
  created_at datetime not null,
  unique key uk_flow_once (tenant_id, action, idempotency_key)
);

这里的 idempotency_key 不是装饰字段。它决定同一个退货动作能不能被重复执行。唯一键里带上 tenant_idaction,是为了避免不同租户、不同动作之间互相污染。

注意我没有把它写成"订单号唯一"。一个订单可能分 SKU 退,也可能有冲销、补偿、人工调整。幂等键要表达具体动作,比如 refund:9001:501

重放一次看看结果

我用 SQLite 做了个小模拟,逻辑和 MySQL 唯一键类似。初始库存是 100,同一个退货请求释放 3 件库存,连续打两次。

text 复制代码
request 1 {"status": 200, "body": {"ok": true, "applied": true, "qty": 103}}
request 2 {"status": 409, "body": {"ok": false, "reason": "duplicate idempotency_key", "qty": 103}}
stock [(10, 501, 103)]
flow [(10, 9001, 501, 'refund_release', 3, 'refund:9001:501')]

这个输出才是我想看到的:第二次请求没有把库存从 103 加到 106,流水表也没有多出第二条 refund_release

接口层可以长这样:

go 复制代码
func Refund(ctx context.Context, req RefundReq) error {
    // 先校验动作权限,不要只校验菜单是否可见
    if err := RequirePermission(ctx, "inventory:refund"); err != nil {
        return err
    }

    return dao.Transaction(ctx, func(ctx context.Context) error {
        key := fmt.Sprintf("refund:%d:%d", req.OrderID, req.SkuID)

        if err := InsertInventoryFlow(ctx, Flow{
            TenantID:       TenantID(ctx),
            OrderID:        req.OrderID,
            SkuID:          req.SkuID,
            Action:         "refund_release",
            Qty:            req.Qty,
            IdempotencyKey: key,
        }); IsDuplicateKey(err) {
            return ErrDuplicateRequest
        } else if err != nil {
            return err
        }

        return AddStock(ctx, TenantID(ctx), req.SkuID, req.Qty)
    })
}

这段代码不是要展示某个框架的最佳写法,而是把边界说清楚:权限先拦动作,事务里先写流水,再改库存;唯一键失败时不能继续更新库存。

权限也要落到动作,不是只落到页面

很多后台权限做到了菜单级别:看不到"库存管理"菜单,就以为安全了。退货这种接口不能靠菜单兜底。

我一般会单独列一张动作权限表:

动作 权限码 失败时应该看到什么
查询库存 inventory:query 无权限返回 403
创建退货 inventory:refund 无权限返回 403
冲销流水 inventory:reverse 无权限返回 403,并写审计日志
导出库存 inventory:export 无权限返回 403,不能只隐藏按钮

如果 Agent 只生成了页面按钮和路由,后端没有动作权限码,这个模块还没验完。前端隐藏按钮只能减少误点,不能替后端做授权。

我拿 XYGo Admin 这种 GoFrame + Vue3 后台骨架做参照,是因为它能展示权限中间件和代码生成器的边界,比如 server/internal/middleware/admin_permission.goserver/internal/logic/gencodes/generate.goserver/internal/logic/gencodes/generate_test.go。但库存退货这种动作,仍然要在业务模块里写清状态和幂等。Issue #9 里有人问 SaaS、多租户和库销存,这类需求不能简单等同于"生成几张表"。

状态表先写出来,不然后面全靠猜

退货还需要一张很朴素的状态表。它不用复杂,但必须提前说清楚。

当前状态 允许动作 下一个状态 备注
paid 申请退货 refund_pending 只创建申请,不释放库存
refund_pending 审核通过 refunded 释放库存,写流水
refunded 再次退货 不允许 幂等命中或直接拒绝
refunded 冲销 refund_reversed 需要单独权限和审计

这张表的作用不是让代码显得正规,而是防止接口作者临时拍脑袋。没有状态表时,最常见的写法是"只要传了 order_id 就执行退货"。这种接口在 demo 里很顺,上线后遇到重试、补偿任务和人工重复点击就开始出问题。

多租户也要在这里一起验。tenant_id=10 的操作员不能退 tenant_id=11 的订单,哪怕订单号和 SKU 都是真的。这个检查不能只放在列表查询里,退货、冲销、导出、补偿任务都要带租户条件。

上线前我会补这几个验收命令

页面能点通以后,我会把下面几条命令放进验收记录里。真实地址按项目改,这里只保留检查意图。

bash 复制代码
# 1. 没登录,应该是 401
curl -i -X POST /api/inventory/refund -d '{"order_id":9001,"sku_id":501,"qty":3}'

# 2. 登录了但没退货权限,应该是 403
curl -i -H 'Authorization: Bearer no_refund_role' \
  -X POST /api/inventory/refund -d '{"order_id":9001,"sku_id":501,"qty":3}'

# 3. 有权限,第一次成功
curl -i -H 'Authorization: Bearer refund_role' \
  -H 'Idempotency-Key: refund:9001:501' \
  -X POST /api/inventory/refund -d '{"order_id":9001,"sku_id":501,"qty":3}'

# 4. 同一个幂等键重放,库存不能再次变化
curl -i -H 'Authorization: Bearer refund_role' \
  -H 'Idempotency-Key: refund:9001:501' \
  -X POST /api/inventory/refund -d '{"order_id":9001,"sku_id":501,"qty":3}'

还要查一条审计日志。日志里至少应该有 tenant_idoperator_idactionorder_idsku_ididempotency_key 和处理结果。没有这些字段,出了问题只能靠猜。

怎么判断这轮验收过了

我会给退货接口定几个很具体的通过条件。

第一,库存余额和流水条数能对上。执行一次退货后,库存只增加对应数量,流水只增加一条。第二,同一个 Idempotency-Key 重放时,接口返回要明确,不能一边说成功一边继续改库。第三,换一个没有退货权限的 token,必须是 403,不能因为前端按钮被隐藏就不测后端。第四,换一个租户的订单号,接口不能查到或不能操作。第五,审计日志能看出谁在什么时候对哪张单做了什么动作。

这些条件看起来比"页面能不能保存"麻烦,但它们更接近上线后的故障现场。库存错一次,后面补账、客服解释和数据修复都会很难看。

生成器能省事,但别替它背锅

代码生成器和 Agent 的价值,应该放在重复结构上:表单、列表、路由、基础权限码、测试骨架、字段同步。它们能把第一版拉起来,也能减少手写低级错误。

业务动作别交给它猜。退货能不能撤销、冲销能不能反冲、库存释放要不要等退款成功、跨租户导出怎么限制,这些都不是 CRUD 模板能自动知道的东西。

所以我现在更愿意把后台生成后的第一轮验收改成动作清单,而不是页面清单。页面能打开只是第一步。退货请求重放一次,库存没变错,权限没绕过,日志能追到人,这个模块才开始接近能上线。

你们现在用 Agent 或代码生成器做后台时,会先验页面,还是先验这种业务动作?

相关推荐
qq_22589174661 小时前
基于Python的城市内涝积涝监测数据可视化分析系统
后端·python·信息可视化·数据分析·django
苏三说技术1 小时前
为什么越来越多人用Apache Tika?
后端
bittersuite1 小时前
LeNet,AlexNet
人工智能·深度学习·机器学习
Zane19941 小时前
Lock 接口与 AQS 核心原理:手写理解一把可重入锁是怎么运作的
java·后端
Full Stack Developme1 小时前
SpringBoot 整合 Druid 并列出参数清单
java·spring boot·后端
名不经传的养虾人1 小时前
从0到1:企业级AI项目迭代日记 Vol.77|隔离不只是数据,还有进程、上下文和依赖
大数据·人工智能·ai编程·企业ai·多agent协作
qq_454245031 小时前
.clinerules 系统提示词价值评判
人工智能·架构
俊哥V1 小时前
AI一周事件 · 2026-07-22 至 2026-07-28
人工智能·ai
一知半解仙2 小时前
从0到1!Spring Cloud微服务无缝集成AI智能体:架构设计+核心源码+生产落地全指南
人工智能·spring cloud·微服务