日期:2026-08-05
需求来源:生产环境订单详情误锁(ORD202608040002:admin 打开详情后陈林看到「锁定编辑中」)
已确认口径:方案 C(查看不加锁,仅写操作加锁)+ 关闭解锁 + 锁超时 5 分钟
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 路由定义。lock_operator_id / lock_time)。| 项 | 选择 |
|---|---|
| 加锁时机 | 仅写操作(打开调整/分配等弹窗前加锁),查看详情不加锁 |
| 解锁时机 | 写弹窗关闭 / 取消 / 提交成功 / 页面卸载时解锁自己的锁 |
| 锁超时 | 5 分钟(自 lock_time 起算,超时视为可抢) |
| 强制解锁 | 运营端「强制解锁」可用;super_admin 与 admin 均可强制清锁 |
| 超时单位 | 固定常量(建议后端 ORDER_EDIT_LOCK_TTL_SECONDS = 300),本期不做系统设置项 |
| 方案 | 说明 | 结论 |
|---|---|---|
| A 关闭即解锁 | 关详情解锁 | 作 C 的配套,仅对自己持有的写锁解锁 |
| B 超时失效 | 过期可抢 | 作兜底,TTL=5 分钟 |
| C 查看不加锁 | 只读不加锁,写操作加锁 | 采用 |
fetchOrderDetail → GET /orders/{id}。lock_order。lockInfo(只读快照):
lock_operator_id → isLocked: true,并带锁定人姓名、锁定时间。isLocked: false。isLocked 时展示提示条(可含「强制解锁」);不阻止查看 Tab/内容。以下动作打开弹窗/进入编辑前,先 POST /orders/{id}/lock:
| 写操作 | 前端入口 | 对应写 API(提交时仍校验锁) |
|---|---|---|
| 订单调整 | 调整弹窗打开前 | POST .../adjust |
| 分配/更换配送员 | 分配弹窗打开前 | POST .../assign-deliverer(及相关分配接口) |
| 审核取消 | 审核弹窗打开前(若有) | POST .../review-cancel |
| 取消订单 | 取消确认前(若详情内有) | POST .../cancel |
| 完成/强制完成 | 确认前(若详情内有) | POST .../complete、force-complete |
规则:
在以下时机调用 POST /orders/{id}/unlock(仅解自己的锁,失败不阻断关窗):
beforeunload / 组件卸载:尽力 navigator.sendBeacon 或异步 unlock(失败依赖 5 分钟超时)POST /orders/{id}/unlock 且带 force: true(或独立 force-unlock)。admin / super_admin。lockInfo,提示条消失。对上表写接口统一:
400,明确提示锁定人与时间。lock_order(当前用户);处理结束后不在服务端强制 unlock(仍由前端关窗解锁),避免长事务中间被他人抢。超时兜底保证可恢复。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:仅本人可解;无锁视为成功(幂等)。文件:backend/app/api/admin.py
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),减少脏数据。POST /orders/{id}/lock:沿用,走新 lock_order(含 TTL)。
合并重复 unlock 路由为唯一实现;请求体可选:
{ "force": false }
写接口增加统一锁检查辅助函数(含 TTL),覆盖:adjust、assign-deliverer、assign-delivery、review-cancel、cancel、complete、force-complete;其余订单写接口若存在一并挂上。
无需 migration;仅逻辑变更。
文件:web-admin/src/views/system/order/index.vue + web-admin/src/api/business.ts
fetchOrderLock、fetchOrderUnlock({ force?: boolean })。lockInfo 映射修正;提示条仅在「他人持有未过期锁」时显示。force: true 后刷新详情。viewVisible 关闭与 onBeforeUnmount / beforeunload 尽力 unlock。| 风险 | 缓解 |
|---|---|
| 旧客户端仍依赖「打开详情即占锁」 | 无;产品明确改为查看不加锁 |
| 关页 unlock 失败 | 5 分钟 TTL |
| 两段 unlock 路由重复 | 合并为一 |
| 生产已有残留锁 | 部署后:过期可抢;或运维一次性清空 lock_*(可选运维脚本,非必须) |
backend/app/crud/order.py:TTL + force unlockbackend/app/api/admin.py:详情去自动锁、lockInfo 字段、合并 unlock、写接口校验web-admin/src/api/business.ts:lock/unlock APIweb-admin/src/views/system/order/index.vue:加解锁时机、强制解锁、字段映射lock_operator_id/lock_time)无。超时已确认为 5 分钟;方案已确认为 C + 关闭解锁 + TTL。