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

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_id 和 action,是为了避免不同租户、不同动作之间互相污染。

注意我没有把它写成"订单号唯一"。一个订单可能分 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.go、server/internal/logic/gencodes/generate.go 和 server/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_id、operator_id、action、order_id、sku_id、idempotency_key 和处理结果。没有这些字段,出了问题只能靠猜。

怎么判断这轮验收过了

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

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

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

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

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

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

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

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

相关推荐
sell-cloud24 分钟前
Prompt Engineering要凉了?Fable 5.5能“猜“意图,但银行正在封杀AI Agent——企业AI的真正护城河是什么
人工智能·prompt
Navicat中国2 小时前
Navicat 18 内置 AI 助手让数据库开发从繁琐操作迈向智能协作
大数据·人工智能·数据库开发
4SAPI2 小时前
多模型请求路由怎么设计?成本、速度和能力如何平衡
人工智能·python·gateway·php
魔乐社区2 小时前
翼芯・智算测试与适配优化专区,让国产算力“选得准、跑得通、用得好“
人工智能·开源·大模型
Java爱好狂.4 小时前
Java初学者如何一周速通SpringBoot!
java·spring boot·后端·spring·高并发·架构师·java面试
XCDoris4 小时前
郴州logo设计机构
大数据·人工智能·python
Goboy5 小时前
一个人带三个Agent,我用全新TRAE从0到1搭了个资讯周刊站
人工智能·trae
俊哥V5 小时前
每日 AI 研究简报 · 2026-10-19
人工智能·ai
AI_AGENT_DEV_AI5 小时前
AI英语教培智能体的开发
人工智能
物联网软硬件开发-轨物科技5 小时前
【技术白皮书】光伏清检一体化机器人系统解决方案技术白皮书
大数据·运维·网络·人工智能·机器人