2026-08-05-web-admin-order-edit-lock-design.md 8.7 KB

Web-Admin 订单编辑锁「查看不加锁」改造设计

日期:2026-08-05
需求来源:生产环境订单详情误锁(ORD202608040002:admin 打开详情后陈林看到「锁定编辑中」)
已确认口径:方案 C(查看不加锁,仅写操作加锁)+ 关闭解锁 + 锁超时 5 分钟

1. 背景与问题

1.1 现状

  • 打开订单详情 GET /api/web/admin/orders/{id} 时,后端会调用 order_crud.lock_order,将当前用户写入 biz_order.lock_operator_id / lock_time。
  • 关闭详情弹窗前端不调用解锁接口,锁长期残留。
  • 他人再打开同一订单详情时抢锁失败,展示「此订单已被锁定编辑中…」。
  • 前端读取 lockInfo.lockBy / lockTime,后端返回 lockedBy / lockedTime,导致锁定人/时间为空。
  • 「强制解锁」按钮未绑定事件。
  • admin.py 中存在两处 POST /orders/{order_id}/unlock 路由定义。

1.2 目标

  • 多人只读查看互不影响(无感)。
  • 并发写操作仍互斥(调整、分配配送等)。
  • 异常退出可自愈(5 分钟超时后可被抢锁)。
  • 正常关闭写弹窗即解锁;管理员可强制解锁。

1.3 非目标

  • 不改表结构(继续使用 lock_operator_id / lock_time)。
  • 不改造小程序端订单流程(除非后续发现同类锁逻辑)。
  • 不做分布式锁中间件(Redis 等);维持 DB 字段方案。

2. 已确认决策

项 选择
加锁时机 仅写操作(打开调整/分配等弹窗前加锁),查看详情不加锁
解锁时机 写弹窗关闭 / 取消 / 提交成功 / 页面卸载时解锁自己的锁
锁超时 5 分钟(自 lock_time 起算,超时视为可抢)
强制解锁 运营端「强制解锁」可用;super_admin 与 admin 均可强制清锁
超时单位 固定常量(建议后端 ORDER_EDIT_LOCK_TTL_SECONDS = 300),本期不做系统设置项

3. 方案对比(回顾)

方案 说明 结论
A 关闭即解锁 关详情解锁 作 C 的配套,仅对自己持有的写锁解锁
B 超时失效 过期可抢 作兜底,TTL=5 分钟
C 查看不加锁 只读不加锁,写操作加锁 采用

4. 行为设计

4.1 查看详情

  1. 前端 fetchOrderDetail → GET /orders/{id}。
  2. 后端不再调用 lock_order。
  3. 响应仍带 lockInfo(只读快照):
    • 若存在未过期且非空的 lock_operator_id → isLocked: true,并带锁定人姓名、锁定时间。
    • 否则 isLocked: false。
  4. 前端:isLocked 时展示提示条(可含「强制解锁」);不阻止查看 Tab/内容。

4.2 进入写操作(加锁)

以下动作打开弹窗/进入编辑前,先 POST /orders/{id}/lock:

写操作 前端入口 对应写 API(提交时仍校验锁)
订单调整 调整弹窗打开前 POST .../adjust
分配/更换配送员 分配弹窗打开前 POST .../assign-deliverer(及相关分配接口)
审核取消 审核弹窗打开前(若有) POST .../review-cancel
取消订单 取消确认前(若详情内有) POST .../cancel
完成/强制完成 确认前(若详情内有) POST .../complete、force-complete

规则:

  • 加锁成功 → 打开弹窗。
  • 加锁失败(他人未过期锁)→ Toast/提示,不打开弹窗。
  • 若锁已过期或无锁 → 抢锁成功。

4.3 退出写操作(解锁)

在以下时机调用 POST /orders/{id}/unlock(仅解自己的锁,失败不阻断关窗):

  • 写弹窗关闭 / 取消
  • 写操作提交成功后
  • 详情整体关闭时:若当前用户仍持有该单写锁,一并 unlock
  • beforeunload / 组件卸载:尽力 navigator.sendBeacon 或异步 unlock(失败依赖 5 分钟超时)

4.4 强制解锁

  • 点击「强制解锁」→ POST /orders/{id}/unlock 且带 force: true(或独立 force-unlock)。
  • 权限:admin / super_admin。
  • 成功后刷新详情 lockInfo,提示条消失。

4.5 写 API 服务端校验

对上表写接口统一:

  1. 若存在他人锁且未过期 → 400,明确提示锁定人与时间。
  2. 若锁已过期 → 允许操作;建议在事务内将过期锁清掉或改写为当前操作人(短锁),操作结束再清(与前端 unlock 双保险)。
  3. 若无锁:本期允许直接提交(兼容未走前端加锁的调用方);推荐写接口入口处「无锁则临时加当前用户锁」,响应/结束后由前端 unlock 清理。
    落地取舍(本期拍板): 写接口在处理前若无有效锁,则自动 lock_order(当前用户);处理结束后不在服务端强制 unlock(仍由前端关窗解锁),避免长事务中间被他人抢。超时兜底保证可恢复。

5. 后端改造

5.1 CRUD:lock_order / unlock_order

文件:backend/app/crud/order.py

  • 新增常量或参数:ttl_seconds: int = 300。
  • lock_order:
    • 若 lock_operator_id 为空 → 加锁。
    • 若等于当前用户 → 刷新 lock_time。
    • 若为他人且 now - lock_time > ttl(或 lock_time 为空视为过期)→ 允许抢锁。
    • 若为他人且未过期 → ValueError。
  • unlock_order(operator_id, force=False):
    • force=True:清空锁,不校验持有人。
    • force=False:仅本人可解;无锁视为成功(幂等)。

5.2 API

文件:backend/app/api/admin.py

  1. get_order:删除自动 lock_order;组装只读 lockInfo:

    {
     "isLocked": true,
     "lockBy": "系统管理员",
     "lockById": "SYS_ADMIN_001",
     "lockTime": "2026-08-05 09:45:02",
     "isExpired": false
    }
    
    • 字段名与前端统一用 lockBy / lockTime(废弃/不再依赖 lockedBy/lockedTime)。
    • lockBy 优先 real_name,其次 username。
    • 已过期锁:isLocked: false(或 isLocked: true + isExpired: true 且前端不挡写操作);推荐展示层视为未锁,后台可异步清理,本期在 get_order 读到过期锁时可选顺手清空(一次 UPDATE),减少脏数据。
    1. POST /orders/{id}/lock:沿用,走新 lock_order(含 TTL)。

    2. 合并重复 unlock 路由为唯一实现;请求体可选:

      { "force": false }
      
  2. 写接口增加统一锁检查辅助函数(含 TTL),覆盖:adjust、assign-deliverer、assign-delivery、review-cancel、cancel、complete、force-complete;其余订单写接口若存在一并挂上。

5.3 不改库表

无需 migration;仅逻辑变更。


6. 前端改造

文件:web-admin/src/views/system/order/index.vue + web-admin/src/api/business.ts

  1. API 封装:fetchOrderLock、fetchOrderUnlock({ force?: boolean })。
  2. 详情:lockInfo 映射修正;提示条仅在「他人持有未过期锁」时显示。
  3. 写弹窗打开前 lock;关闭/成功 unlock。
  4. 「强制解锁」调用 force: true 后刷新详情。
  5. viewVisible 关闭与 onBeforeUnmount / beforeunload 尽力 unlock。

7. 兼容与风险

风险 缓解
旧客户端仍依赖「打开详情即占锁」 无;产品明确改为查看不加锁
关页 unlock 失败 5 分钟 TTL
两段 unlock 路由重复 合并为一
生产已有残留锁 部署后:过期可抢;或运维一次性清空 lock_*(可选运维脚本,非必须)

8. 验收标准

  1. A、B 两运营账号同时打开同一订单详情:均无「锁定编辑中」挡看(除非另一方已进入写弹窗且未过期)。
  2. A 打开「订单调整」后,B 再开调整:B 加锁失败并提示;A 关闭弹窗后 B 可加锁成功。
  3. A 加锁后杀死浏览器;约 5 分钟后 B 可成功加锁并提交。
  4. 他人锁未过期时,管理员点「强制解锁」后提示消失,可进入写操作。
  5. 提示条展示锁定人姓名与锁定时间(非空)。
  6. 回归:调整订单、分配配送员主流程可用。

9. 实施范围清单

  • backend/app/crud/order.py:TTL + force unlock
  • backend/app/api/admin.py:详情去自动锁、lockInfo 字段、合并 unlock、写接口校验
  • web-admin/src/api/business.ts:lock/unlock API
  • web-admin/src/views/system/order/index.vue:加解锁时机、强制解锁、字段映射
  • (可选)一次性运维 SQL:清空历史残留锁(仅 lock_operator_id/lock_time)

10. 开放项

无。超时已确认为 5 分钟;方案已确认为 C + 关闭解锁 + TTL。