# 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`: ```json { "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),减少脏数据。 2. **`POST /orders/{id}/lock`**:沿用,走新 `lock_order`(含 TTL)。 3. **合并重复 `unlock` 路由**为唯一实现;请求体可选: ```json { "force": false } ``` 4. 写接口增加统一锁检查辅助函数(含 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**。