--- schema: "hl-changelog/v2" ticket: "7980" title: "出行人删除/批量编辑与发票申请三组写口补同一把 @Lock4j name,新增可重试冲突码 100503(会传导至小程序 C 端)" consumer: "multiple" author: "wx(GIT)" change_type: "修改接口" backend_status: "deployed" gateway_status: "not_required" frontend_status: "not_required" frontend_owner: "mmg" frontend_ref: "" target_release: "" verified_at: "" status_note: "PR #8049 已 squash 合并 dev-v3(合并提交 43c53c4c9)。2026-09-20 23:24 已部署测试服 hl-order-service-v3:滚动更新两实例,部署前 COMMIT=c35251b07、部署后 COMMIT=ba8aab3ab,8186 与 8086 各 12 秒内起监听。`git merge-base --is-ancestor 43c53c4c9 ba8aab3ab` 返回 ANCESTOR_YES。gateway_status=not_required:端点路径/方法零变化,本次与网关路由无关,不是漏验证。🔴 本次未在测试服做功能调用取证,这是有意为之而不是遗漏:本组端点是删除出行人与批量改出行人,调用它们会真实删改订单下的出行人行;测试服订单数据被多个会话共用,删除撤不回来,副作用会落在一个不知情的人头上。互斥本身由落库级并发 IT `Lock4jSharedNameConcurrencyIT` 覆盖(3 条用例,含专为自调用入口 `deleteByInternal` 单写的一条),不变量是「订单至少留 1 位成人」;另有 `Lock4jSharedNameContractTest` 按 SpEL 反查全类、钉死三组的成员集合,能抓住日后新加的第 N 个方法。⚠️ `100503` 在测试服上一次都没有触发过——不是「测过、不会触发」,是没测;临界区短不等于永不超时,更长事务/更大载荷/生产数据量下仍可能出现。mmg 前端实证 2026-09-20:admin 侧调用方 TravelerInfoEditor.vue(:550/863/868)、FunItemAdjustModal.vue(adjustment/submit 经 index.vue)、ApplyModal.vue(:298/329/372)的 catch 均仅复位 loading 并注释「业务码由 request.js 拦截器统一 toast」,成功失败一律读 body code 非 HTTP 状态;100503(HTTP 200+业务码)经拦截器透 message 原样展示,可重试/不静默/不当系统异常三条全满足,故 admin 端零改动,翻 not_required。mp 侧出行人补全/删除与申请发票传导 C 端属 changelogs-v2-mp 系列,由 mp loop 消费,本仓不越权。" updated_at: "2026-09-20" base: "dev-v3" --- # order-v3: 出行人删除/批量编辑与发票申请三组写口补同一把 @Lock4j name,新增可重试冲突码 100503(会传导至小程序 C 端) > **存放目录**: > - 一期(v2,无 `order-v3` 标签的工单)→ `changelogs/{YYYY-MM}/` > - 二期(v3,`order-v3` 标签的工单)→ `changelogs-v2/{YYYY-MM}/` > > **服务**: hl-order-service-v3(另涉 hl-mp-service 四个透传/BFF 入口) > **PR**: #8049 > **Issue**: #7980(AC-7 第一/三/四组) > **日期**: 2026-09-20 > **影响范围**: 管理后台「订单出行人」删除/批量编辑、「订单调整」提交(出行人 tab)、「发票管理」申请开票 + 小程序 C 端「出行人补全/删除」「申请发票」 --- ## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写) - 三组写口的 `@Lock4j` 都写了**逐字相同**的 `keys`,却都**没写 `name`**——lock4j 2.2.7 缺省 `name` 时用「类全限定名 + 方法名」当默认值拼进 Redis 键,于是每组内的多个方法**各持一把互不相干的锁**,可以并发互相踩踏: 1. **`traveler:delete:order`**(3 处):`TravelerService.deleteTraveler`(admin)/ `mpDeleteTraveler`(mp)/ `deleteByInternal`(internal Feign,且它经 `this.deleteTraveler(...)` 自调用,不走 Spring 代理,之前**一次都没触发过**锁)。「最后 1 位成人禁删」是内存计数的 check-then-act,`order_traveler` 无 `@Version`、无行锁复读,没有第二道锁兜底——两个并发删除请求各读到「还有 2 个成人」的旧快照就会把成人删到 0。 2. **`traveler:batch-edit`**(3 处):`TravelerService.batchEditTravelers`(admin 直接批量编辑)/ `batchEditTravelersForAdjustment`(订单调整提交里的出行人 tab)/ `mpBatchEditTravelers`(mp)。「基于内存合并的最终生效集合先校验后写库」同样是 check-then-act,没有第二道锁兜底。 3. **`order-invoice:apply`**(2 处):`InvoiceAdminService.adminApplyInvoice`(admin)/ `InvoiceMpService.mpApplyInvoice`(mp)。两者前置条件相同(同一个 `validateApplyEligibility`,含「一单一票」existsActive 检查),`order_invoice` 无 `UNIQUE`、`OrderInvoice` 无 `@Version`,补 `name` 之前 admin 申请 ∥ mp 申请可各读到 existsActive 旧快照并各插一条申请单。 - 本次给三组各自补上**同一个**显式 `name`(分别为 `"traveler:delete:order"` / `"traveler:batch-edit"` / `"order-invoice:apply"`),组内互斥才真正生效。**接口路径、请求/响应字段、既有业务错误码全部零增删**,唯一契约变化是新增一个可能返回的失败码 **`100503`**,**HTTP 状态码仍是 200**。 - 🔴 **新增的失败路径会到达小程序 C 端**:出行人补全/删除、申请发票均是 mp 端用户自助操作,命中锁冲突时前端会等待最多约 3 秒后收到 `code=100503`("资源被占用,请稍后重试")。**传输层仍是 HTTP 200**,失败信息在响应体的 `code` 字段里——前端如果只看 HTTP 状态码会把这次失败误判为成功。 - 🔴 **本 PR 只做 AC-7 里判定为「要修」的三组**。同一份判定还覆盖了另外三组,均**不修**(不在本文档「二、变更接口清单」范围内,行为未变,仅供背景参考): - `order-ins:` 组(保险 `purchaseCore`):判定为「有意不加 name」——`purchaseCore` 本身没有按单去重逻辑,补锁对「同单不重复出单」零贡献;真正的缺口在工单未列出的 `autoPurchase`/`autoPurchaseByScheme`(连 `@Lock4j` 都没有),本轮只订正了两处描述这把锁效果的假注释,完整修复另起工单,本文档不涉及。 - `material:tag:name:`(resource 服务):有 `uk_tag_name` 唯一索引兜底,判定不修。 - `fleet:msg-tpl:default:`(fleet 服务):有 InnoDB 行锁兜底,判定不修。 --- ## 一、背景 工单 #7980 第四节原文对以下几处描述与代码不符,本 PR 已订正(不影响本次「要不要补锁」的结论,只是让文档准确):① 第 5 组「同名标签双插(无唯一索引)」——`material_tag` 实际**有** `UNIQUE KEY uk_tag_name`;② 第 3 组两处行号已漂移(AC-4 改动注释所致),实际为 `TravelerService.java:126`/`:150`(现已改为本次改动后的真实行号);③ 第 3 组组大小实际是 **3** 个方法(`deleteTraveler`/`mpDeleteTraveler`/`deleteByInternal`)不是 2 个,其中 `deleteByInternal` 因自调用问题此前**从未触发过**锁,是最危险的一处。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 出行人软删(admin) | POST | `/v3/admin/order/{id}/traveler/{travelerId}/delete` | 新增可能返回的错误码 | 与 §2/§3 共用 `traveler:delete:order` 锁 | | 2 | 客户删除出行人(mp internal) | POST | `/v3/internal/mp/order/{id}/traveler/{travelerId}/delete` | 新增可能返回的错误码 | 经 hl-mp-service 到达小程序 C 端 | | 3 | 跨服务删除出行人(internal Feign) | DELETE | `/v3/internal/order/orders/{orderId}/travelers/{travelerId}` | 新增可能返回的错误码 | 当前零跨服务调用方,见业务边界 | | 4 | 批量 upsert 出行人(admin) | POST | `/v3/admin/order/{id}/traveler/batch-edit` | 新增可能返回的错误码 | 与 §5/§6 共用 `traveler:batch-edit` 锁 | | 5 | 调整订单统一提交(含出行人 tab) | POST | `/v3/admin/order/{id}/adjustment/submit` | 新增可能返回的错误码(条件触发) | 仅当请求体含出行人增删改时才会命中本组锁 | | 6 | 客户出行人补全(mp internal) | POST | `/v3/internal/mp/order/{id}/traveler/batch-edit` | 新增可能返回的错误码 | 经 hl-mp-service 到达小程序 C 端 | | 7 | 定制师代客申请发票(admin) | POST | `/v3/admin/order/{orderId}/invoice/apply` | 新增可能返回的错误码 | 与 §8 共用 `order-invoice:apply` 锁 | | 8 | 用户申请开票(mp internal) | POST | `/v3/internal/mp/order/{orderId}/invoice/apply` | 新增可能返回的错误码 | 经 hl-mp-service 到达小程序 C 端 | --- ## 三、接口详情 ### 1. 出行人软删(admin) `POST /v3/admin/order/{id}/traveler/{travelerId}/delete` **VO**: `无 ReqVO(纯路径参数,无请求体) → Boolean` #### 使用场景 管理后台订单详情页,定制师/客服删除某个出行人(已签电子合同禁删;订单最后 1 位成人禁删)。本次改动不涉及入参/出参,只新增一条「被本组另外 2 个写口占用同一把锁」的失败分支。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | id | Path | Long | ✅ | - | 订单 ID | | travelerId | Path | Long | ✅ | - | 出行人 ID | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | data | Boolean | 恒为 `true`(失败走异常,不会返回 `false`) | #### 请求示例 ```http POST /v3/admin/order/1234567890123456789/traveler/70123456789012/delete Authorization: Bearer {token} ``` #### 响应示例 ```json { "code": 200, "message": "成功", "data": true, "success": true } ``` #### 空数据 / 降级响应 本接口无「空数据」/降级概念(成功恒返回 `true`,失败一律抛业务异常)。 #### 错误响应 ```json { "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 401 | 未登录(网关拦截) | | 581102 | 订单不存在 | | 581110 | 出行人 ID 不属于该订单 | | 581106 | 已签电子合同,禁止删除出行人 | | 581107 | 出行人是订单最后 1 位成人,禁止删除 | | 100502 | 3 秒幂等窗口内重复提交(键=id+travelerId) | | **100503(本次新增)** | 抢锁等待超过 3 秒(与 §2 mp 删除、§3 internal Feign 删除共用同一把 `traveler:delete:order` 锁),**可重试** | #### 业务边界 - 幂等:`@Idempotent`(`keyPrefix=traveler:delete`)3 秒窗口,键含订单 ID + 出行人 ID。 - 抢锁失败(100503)时该次请求未进入方法体,零写入——「最后 1 位成人」的计数不会被并发破坏。 - 软删:级联软删桥接表 `order_transport_plan_traveler`,并写 `order_status_log` 流水(`TRAVELER_DELETE`)。 - 已签约阶段删人会触发"作废旧合同/保单 + 重新签约/投保"事件(按订单状态白名单过滤),不受本次改动影响。 --- ### 2. 客户删除出行人(mp internal) `POST /v3/internal/mp/order/{id}/traveler/{travelerId}/delete` **VO**: `无 ReqVO(纯路径参数,无请求体) → Void` #### 使用场景 小程序 C 端用户在「我的订单」自助删除出行人,仅订单确认前(PENDING_PAY/CUSTOMIZING)放行,且只能删自己名下订单的出行人。**本端点是 order-v3 的 internal 路径,C 端不直接访问它**,而是经 hl-mp-service 的公网入口 `DELETE /mp/order/{orderId}/traveler/{travelerId}`(`MpOrderTravelerController`)透传调用。本次改动不涉及入参/出参,只新增一条抢锁超时的失败分支。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | id | Path | Long | ✅ | - | 订单 ID | | travelerId | Path | Long | ✅ | - | 出行人 ID | (`userId` 从 JWT 解析,前端不传,由网关透传 `X-User-Id`。) #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | data | null | 无返回数据 | #### 请求示例 ```http DELETE /mp/order/1234567890123456789/traveler/70123456789012 Authorization: Bearer {token} ``` (以上为小程序 C 端实际调用的公网地址;order-v3 侧 internal 路径见本节标题。) #### 响应示例 ```json { "code": 200, "message": "成功", "data": null, "success": true } ``` #### 空数据 / 降级响应 本接口无「空数据」/降级概念。 #### 错误响应 ```json { "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 581122 | 订单不属于当前用户(越权) | | 581145 | 订单已确认,出行人信息不可再经小程序修改(须走 admin) | | 581102/581110/581106/581107 | 同 §1(复用 `deleteTraveler` 全部业务规则) | | 100502 | 3 秒幂等窗口内重复提交 | | **100503(本次新增)** | 抢锁等待超过 3 秒(与 §1 admin 删除、§3 internal Feign 删除共用同一把 `traveler:delete:order` 锁),**可重试** | #### 业务边界 - 幂等:`@Idempotent` 3 秒窗口(键=订单 ID)。 - 越权校验:`order.userId == 当前登录 userId`,不满足统一返 581122,不额外区分"不存在"与"无权限"。 - 状态门禁:仅 `PENDING_PAY`/`CUSTOMIZING` 放行,订单确认后(`PENDING_DEPARTURE` 起)及已完成/已取消一律禁止,防止客户改动触发合同作废重签/退保重投保。 - 抢锁失败(100503)时零写入,业务规则全部委托 `deleteTraveler`,见 §1。 --- ### 3. 跨服务删除出行人(internal Feign) `DELETE /v3/internal/order/orders/{orderId}/travelers/{travelerId}` **VO**: `无 ReqVO(纯路径参数,无请求体) → Boolean` #### 使用场景 供其他微服务通过 Feign 跨服务删除出行人(无 JWT 鉴权,依赖网络隔离,Gateway 不暴露 `/internal/**` 至公网)。🔴 全仓 grep 确认**当前零跨服务调用方**——没有任何 `@FeignClient` 接口声明指向本路径,是本组三处里唯一没有现存调用方的一处。本次改动不涉及入参/出参,只是把此前**完全裸奔**(因自调用问题一次都没触发过锁)的这条链路纳入本组互斥。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | orderId | Path | Long | ✅ | - | 订单 ID | | travelerId | Path | Long | ✅ | - | 出行人 ID | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | data | Boolean | 恒为 `true`(委托 `deleteTraveler`,失败走异常) | #### 请求示例 ```http DELETE /v3/internal/order/orders/1234567890123456789/travelers/70123456789012 ``` (跨服务 Feign 调用,无鉴权头。) #### 响应示例 ```json { "code": 200, "message": "成功", "data": true, "success": true } ``` #### 空数据 / 降级响应 本接口无「空数据」/降级概念,也无当前真实流量。 #### 错误响应 ```json { "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 581102/581110/581106/581107 | 同 §1(委托 `deleteTraveler`,`adminId` 传 `null`) | | **100503(本次新增)** | 抢锁等待超过 3 秒(与 §1 admin 删除、§2 mp 删除共用同一把 `traveler:delete:order` 锁),**可重试** | #### 业务边界 - 无 `@Idempotent`(internal Feign 端点通常由调用方自行保证幂等)。 - 🔴 **本次改动前,本端点的调用链(`deleteByInternal` → `this.deleteTraveler(...)`)是自调用,不经 Spring 代理,`deleteTraveler` 上的 `@Lock4j` 在这条链路上此前一次都不会触发**——`deleteByInternal` 方法本身现已补上独立的 `@Lock4j(name="traveler:delete:order")`,才真正纳入互斥;这不是"补 name"本身的效果,而是本次一并修的一个更深的问题(自调用丢锁)。 - 当前零调用方,不代表本条不需要修:与 A/B/C 三份 changelog 里 C 份(库存扣减)同类判定——回归面可实证为零,但闸口本身要补齐。 - 抢锁失败(100503)时零写入。 --- ### 4. 批量 upsert 出行人(admin) `POST /v3/admin/order/{id}/traveler/batch-edit` **VO**: `TravelerBatchEditReqVO → TravelerBatchEditRespVO` #### 使用场景 管理后台订单详情页,定制师/客服批量新增/更新出行人(id=null 新增,id 非 null 更新,整体事务,≤30 行)。本次改动不涉及入参/出参字段,只新增一条「被本组另外 2 个写口占用同一把锁」的失败分支。 #### 入参 路径参数 + Body: | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | id | Path | Long | ✅ | - | 订单 ID | | travelers | Body | List\ | ✅ | `@NotEmpty`,≤30 | 待修改出行人数组 | | travelers[].id | Body | Long | - | null=新增/非 null=更新 | 更新时必须属于该 orderId | | travelers[].name | Body | String | - | - | 姓名 | | travelers[].gender | Body | String | - | 1=男/2=女/0=未知 | 性别 | | travelers[].birthday | Body | LocalDate | ✅ | - | 出生日期,后端按年龄自动派生出行人类型 | | travelers[].idType | Body | String | - | 字典 id_card_type | 证件类型 | | travelers[].idNo | Body | String | - | 格式按证件类型校验 | 证件号(明文传,DB 加密) | | travelers[].nationality | Body | String | - | 不可空字符串 | 国籍(12301 必报字段) | | travelers[].race | Body | String | - | 不可空字符串 | 民族(12301 必报字段) | | travelers[].phone | Body | String | - | 11 位数字 | 出行人手机(明文传,DB 加密) | | travelers[].email | Body | String | - | 宽松格式 | 电子邮箱 | | travelers[].emergencyContact | Body | String | - | - | 紧急联系人姓名 | | travelers[].emergencyPhone | Body | String | - | - | 紧急联系人电话 | | travelers[].roomGroupNo | Body | Integer | - | 不超家庭数上限 | 同住分组号 | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | createdCount | Integer | 本次新增的出行人数(id=null 行) | | updatedCount | Integer | 本次更新的出行人数(id 非 null 行) | | completedCount | Integer | 本次操作后变为 COMPLETED 的行数 | | pendingCount | Integer | 仍为 PENDING 的行数 | | allCompleted | Boolean | 该订单所有出行人是否已完善 | #### 请求示例 ```json { "travelers": [ { "id": null, "name": "张三", "gender": "1", "birthday": "1985-08-12", "idType": "ID_CARD", "idNo": "220103198508121234", "nationality": "中国", "race": "汉族", "phone": "13812342046" } ] } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "data": { "createdCount": 1, "updatedCount": 0, "completedCount": 1, "pendingCount": 0, "allCompleted": true }, "success": true } ``` #### 空数据 / 降级响应 本接口无「空数据」概念(`travelers` 要求非空),无降级路径。 #### 错误响应 ```json { "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 401 | 未登录(网关拦截) | | 581105 | 批量大小超限(>30) | | 581102 | 订单不存在 | | 581119 | 出行人证件号重复(同批) | | 581101 | 12301 必报字段缺失(国籍/民族为空字符串) | | 581112/581113/581114 | 证件号/手机号格式非法、出生日期晚于今天 | | 581103/581104 | 性别编码不合法、同住分组号超出上限 | | 581118 | 新增项缺少必填信息(姓名/证件类型/证件号至少一项) | | 581110 | 更新项不属于该订单 | | 581111 | 已签电子合同后禁止修改证件号 | | 581136 | 成人手机号硬门禁(至少一名成人须填手机号) | | 100502 | 3 秒幂等窗口内重复提交(键=订单 ID) | | **100503(本次新增)** | 抢锁等待超过 3 秒(与 §5 订单调整提交、§6 mp 出行人补全共用同一把 `traveler:batch-edit` 锁),**可重试** | #### 业务边界 - 幂等:`@Idempotent` 3 秒窗口(键=订单 ID)。 - 抢锁失败(100503)时零写入——所有校验(配额、去重、必填矩阵)都在事务内完成,锁竞争失败不会产生任何部分写入。 - 校验顺序:批量大小 → 订单存在 → 身份证去重 → 姓名硬拦截 → 12301 字段 → 格式类字段 → 新增/更新分支各自的必填与归属校验 → 成人手机号硬门禁(基于内存合并的最终生效集合),任一失败整事务回滚。 - 老数据兼容:无字段变化。 --- ### 5. 调整订单统一提交(含出行人 tab) `POST /v3/admin/order/{id}/adjustment/submit` **VO**: `AdjustmentSubmitReqVO → AdjustmentSubmitRespVO` #### 使用场景 ⚠️ 本接口是订单调整弹窗的统一提交入口,覆盖人数/行程/住宿/用车等多个子领域,**本节只描述与本次改动相关的「出行人 tab」部分**,其余子领域(改期/行程编辑/房需求/车需求)字段与行为均**不在本次改动范围内、未变化**,完整入参见既有产品/接口文档。当请求体 `updates.people.travelers`(或兼容旧契约的 `updates.travelers`)含新增/更新/删除任一项时,后端内部会调用与 §4 admin 批量编辑**同名**的锁(`traveler:batch-edit`),本次改动前该调用完全不受这把锁保护——补 `name` 之后,本接口在处理出行人变更时会真的把 §4/§6 挡在门外。 #### 入参(仅出行人相关部分,其余字段见既有文档) | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | id | Path | Long | ✅ | - | 订单 ID | | updates.people.travelers.add | Body | List\ | - | - | 新增出行人列表 | | updates.people.travelers.update | Body | List\ | - | 含 id 字段 | 更新出行人列表 | | updates.people.travelers.remove | Body | List\ | - | - | 删除出行人 id 列表 | | updates.people.travelers.add[].name | Body | String | - | - | 姓名 | | updates.people.travelers.add[].idType | Body | String | - | - | 证件类型 | | updates.people.travelers.add[].idNo | Body | String | - | - | 证件号(脱敏回显,编辑回传按未修改处理规则见 §4 同款字段) | | updates.people.travelers.add[].birthday | Body | LocalDate | 条件必填 | 新增必填 | 出生日期,用于派生出行人类型 | | updates.people.travelers.add[].travelerType | Body | String | - | ADULT/CHILD | 出行人类型 | | updates.people.travelers.add[].remark | Body | String | - | - | 备注 | | updates(其余字段:schedule/itinerary/hotelRequirement/vehicleRequirement/transferRequirement) | Body | - | - | 见既有文档 | 与本次改动无关,未变化 | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | success | Boolean | 提交是否成功(极简响应,失败统一走全局异常处理器返回 `Result{code,message}`) | #### 请求示例(仅出行人 tab 片段) ```json { "updates": { "people": { "travelers": { "add": [ { "name": "李四", "idType": "ID_CARD", "idNo": "110101198801011234", "birthday": "1988-01-01" } ], "remove": [70123456789013] } } } } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "data": { "success": true }, "success": true } ``` #### 空数据 / 降级响应 `updates` 各子域均可为 null(按需填),但不能全 null(否则 `AdjustmentErrorCode.ADJUST_NO_EFFECTIVE_CHANGE`)。不含出行人变更时本次改动完全不生效,走原有逻辑。 #### 错误响应 ```json { "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 401 | 未登录(网关拦截) | | (出行人相关)581101/581103/581104/581110-581114/581118/581119/581136 | 同 §4 校验矩阵,按调整后目标人数校验(区别于 §4 按数据库当前声明人数校验) | | **100503(本次新增,仅当本次提交含出行人变更时可能触发)** | 抢锁等待超过 3 秒(与 §4 admin 批量编辑、§6 mp 出行人补全共用同一把 `traveler:batch-edit` 锁),**可重试** | | (其余子领域错误码) | 与本次改动无关,未变化,不在本文档范围 | #### 业务边界 - 无 `@Idempotent`(本端点覆盖多子领域,幂等策略由前端弹窗层面控制,不在本次改动范围)。 - **配额校验口径与 §4 不同**:本入口按"本次调整的最终人数"校验(`targetDeclaration`),并在校验时排除同一事务中待删除的旧出行人;§4 普通编辑入口按数据库中的当前声明人数校验。 - 抢锁失败(100503)**只影响本次请求整体**——本接口是单一事务原子应用所有子领域改动,出行人锁竞争失败会导致整个 `adjustment/submit` 请求失败回滚,不会出现"其他子域改完、出行人没改完"的部分成功。 - 老数据兼容:无字段变化。 --- ### 6. 客户出行人补全(mp internal) `POST /v3/internal/mp/order/{id}/traveler/batch-edit` **VO**: `TravelerBatchEditReqVO → TravelerBatchEditRespVO` #### 使用场景 小程序 C 端用户自助补全/修改出行人信息,仅订单确认前(`PENDING_PAY`/`CUSTOMIZING`)放行,且只能改自己名下订单。**本端点是 order-v3 的 internal 路径**,C 端经 hl-mp-service 的三个公网入口间接调用:`POST /mp/order/{orderId}/traveler`(新增单个出行人)、`PUT /mp/order/{orderId}/traveler/{travelerId}`(修改单个出行人)、`POST /mp/v3/order/{id}/traveler/batch-edit`(批量补全)。本次改动不涉及入参/出参字段,只新增一条抢锁超时的失败分支。 #### 入参 同 §4(`TravelerBatchEditReqVO`,字段表见上节,不重复列出)。`userId` 从 JWT 解析,前端不传。 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | id | Path | Long | ✅ | - | 订单 ID | | travelers | Body | List\ | ✅ | `@NotEmpty`,≤30 | 字段结构同 §4 | #### 出参 `Result` 字段结构同 §4: | 字段 | 类型 | 说明 | |------|------|------| | createdCount | Integer | 本次新增的出行人数(id=null 行) | | updatedCount | Integer | 本次更新的出行人数(id 非 null 行) | | completedCount | Integer | 本次操作后变为 COMPLETED 的行数 | | pendingCount | Integer | 仍为 PENDING 的行数 | | allCompleted | Boolean | 该订单所有出行人是否已完善 | #### 请求示例 ```json { "travelers": [ { "id": null, "name": "王五", "idType": "ID_CARD", "idNo": "310101199001011234", "birthday": "1990-01-01" } ] } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "data": { "createdCount": 1, "updatedCount": 0, "completedCount": 1, "pendingCount": 0, "allCompleted": true }, "success": true } ``` #### 空数据 / 降级响应 本接口无「空数据」概念(`travelers` 要求非空),无降级路径。 #### 错误响应 ```json { "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 581122 | 订单不属于当前用户(越权) | | 581145 | 订单已确认,出行人信息不可再经小程序修改 | | 581101/581105/581110-581114/581118/581119/581136 | 同 §4 校验矩阵 | | 100502 | 3 秒幂等窗口内重复提交 | | **100503(本次新增)** | 抢锁等待超过 3 秒(与 §4 admin 批量编辑、§5 订单调整提交共用同一把 `traveler:batch-edit` 锁),**可重试** | #### 业务边界 - 幂等:`@Idempotent` 3 秒窗口(键=订单 ID)。 - 越权与状态门禁同 §2。 - 🔴 **本 PR 尤其重要的一处**:`mpBatchEditTravelers` 的方法体最终委托 `this.batchEditTravelers(...)`(自调用),此前该调用同样有绕过代理丢锁的历史问题,本次连同 §4/§5 一并补齐同一 `name`,三个入口现在才真正互斥——补锁前,客户在小程序自助编辑与定制师在 admin 后台同时编辑同一订单的出行人可能互不阻塞(内存计算的配额各按各的旧快照算,静默丢更新)。 - 抢锁失败(100503)时零写入。 - 老数据兼容:无字段变化。 --- ### 7. 定制师代客申请发票(admin) `POST /v3/admin/order/{orderId}/invoice/apply` **VO**: `AdminInvoiceApplyReqVO → AdminInvoiceApplyRespVO` #### 使用场景 管理后台「发票管理」,定制师代客户申请开票(订单状态须 ∈ {CUSTOMIZING, PENDING_DEPARTURE, TRAVELLING, COMPLETED},或已取消且净实收>0;一单一票)。本次改动不涉及入参/出参字段,只新增一条「被 §8 mp 申请占用同一把锁」的失败分支。 #### 入参 路径参数 + Body: | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | orderId | Path | Long | ✅ | - | 订单 ID | | invoiceType | Body | String | ✅ | `@NotBlank`,VAT_NORMAL/VAT_SPECIAL | 发票类型 | | titleType | Body | String | ✅ | `@NotBlank`,COMPANY/PERSONAL | 抬头类型,专票只能开给单位 | | titleName | Body | String | ✅ | `@NotBlank`,≤200 字符 | 抬头名称 | | taxNo | Body | String | 条件必填 | ≤64 字符 | 税号(titleType=COMPANY 或 invoiceType=VAT_SPECIAL 必填) | | bankName | Body | String | 条件必填 | ≤128 字符 | 开户行(invoiceType=VAT_SPECIAL 必填) | | bankAccount | Body | String | 条件必填 | ≤64 字符 | 开户账号(同上) | | registAddress | Body | String | 条件必填 | ≤500 字符 | 注册地址(同上) | | registPhone | Body | String | 条件必填 | ≤32 字符 | 注册电话(同上) | | email | Body | String | ✅ | `@Email`,≤128 字符 | 收件邮箱 | | remark | Body | String | - | ≤500 字符 | 备注/货物或应税劳务名称 | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | id | String(雪花,序列化为字符串) | 发票 ID | | orderId | String | 订单 ID | | status | String | 发票状态(REQUESTED=待开票) | | amount | BigDecimal | 开票金额(申请阶段为 null,开票时才计算写入) | | requestedAt | LocalDateTime | 申请时间 | | requestedBy | String | 申请人(定制师真实姓名) | #### 请求示例 ```json { "invoiceType": "VAT_NORMAL", "titleType": "COMPANY", "titleName": "上海呼籁旅行科技有限公司", "taxNo": "91310115MA1K48XXXX", "email": "finance@hulalv.com" } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "data": { "id": "2100889866400104450", "orderId": "1234567890123456789", "status": "REQUESTED", "amount": null, "requestedAt": "2026-09-20T14:30:25", "requestedBy": "李定制" }, "success": true } ``` #### 空数据 / 降级响应 本接口无「空数据」概念(成功恒返回完整响应),无降级路径。 #### 错误响应 ```json { "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 401 | 未登录(网关拦截) | | 581511 | 该订单已存在有效发票(一单一票) | | 581524 | 订单当前状态不可申请开票 | | 581513 | 发票类型非法 | | 581523 | 增值税专用发票只能开给单位 | | 581514 | 公司抬头或专票必须填写税号 | | 581515 | 增值税专用发票须填写银行及注册信息 | | 581516 | 收件邮箱不能为空 | | 100502 | 5 秒幂等窗口内重复提交(键=订单+发票类型+抬头名称,抬头或类型不同即不拦) | | **100503(本次新增)** | 抢锁等待超过 3 秒(与 §8 mp 申请共用同一把 `order-invoice:apply` 锁),**可重试** | #### 业务边界 - 幂等:`@Idempotent` 5 秒窗口,键含订单+发票类型+抬头名称——**抬头或类型不同即不拦**,替代不了这把锁(两个不同抬头的并发申请仍会分别通过幂等但撞上一单一票)。 - 抢锁失败(100503)时该次请求零写入,`existsActive` 检查与 INSERT 之间不会被并发穿透。 - 开票金额本次申请不写,由后续「完成开票」(另一 PR #8043 的 `issueInvoice`)自动计算写入,与本次改动无关。 - 老数据兼容:无字段变化。 --- ### 8. 用户申请开票(mp internal) `POST /v3/internal/mp/order/{orderId}/invoice/apply` **VO**: `MpInvoiceApplyReqVO → MpInvoiceApplyRespVO` #### 使用场景 小程序 C 端用户自助申请开票(仅本人订单,状态门槛与一单一票规则与 §7 相同)。**本端点是 order-v3 的 internal 路径**,C 端经 hl-mp-service 的公网入口 `POST /mp/v3/order/{orderId}/invoice/apply`(`MpV3CollabInvoiceApplyController`)透传调用。本次改动不涉及入参/出参字段,只新增一条「被 §7 admin 申请占用同一把锁」的失败分支。 #### 入参 路径参数 + Body: | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | orderId | Path | Long | ✅ | - | 订单 ID | | invoiceType | Body | String | ✅ | `@NotBlank`,VAT_NORMAL/VAT_SPECIAL | 发票类型 | | titleType | Body | String | ✅ | `@NotBlank`,COMPANY/PERSONAL | 抬头类型 | | titleName | Body | String | ✅ | `@NotBlank`,≤200 字符 | 抬头名称 | | taxNo | Body | String | 条件必填 | ≤64 字符 | 税号 | | bankName | Body | String | 条件必填 | ≤128 字符 | 开户行 | | bankAccount | Body | String | 条件必填 | ≤64 字符 | 开户账号 | | registAddress | Body | String | 条件必填 | ≤500 字符 | 注册地址 | | registPhone | Body | String | 条件必填 | ≤32 字符 | 注册电话 | | email | Body | String | ✅ | `@Email`,≤128 字符 | 收件邮箱 | | applyReason | Body | String | - | ≤500 字符 | 申请说明 | (`userId` 从 JWT 解析,前端不传。) #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | id | Long | 发票主键 | | orderId | Long | 订单 ID | | status | String | 状态:实际写入值为 `REQUESTED`(与 hl-mp-service 侧 VO 注释所写"固定 APPLIED"不一致,以 order-v3 源码 `InvoiceMpService.mpApplyInvoice` 实际赋值为准) | | amount | BigDecimal | 申请金额(申请阶段为 null) | | applyAt | LocalDateTime | 申请时间 | | triggerPushLogIds | List\ | 触发的通知推送日志 ID 列表(当前恒为空数组) | #### 请求示例 ```json { "invoiceType": "VAT_NORMAL", "titleType": "PERSONAL", "titleName": "张三", "email": "zhangsan@example.com" } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "data": { "id": 2100889866400104460, "orderId": 1234567890123456789, "status": "REQUESTED", "amount": null, "applyAt": "2026-09-20T14:35:10", "triggerPushLogIds": [] }, "success": true } ``` #### 空数据 / 降级响应 本接口无「空数据」概念,无降级路径。 #### 错误响应 ```json { "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 581470 | 订单不存在或不属于当前用户 | | 581511/581524/581513/581523/581514/581515/581516 | 同 §7 校验矩阵(复用 `InvoiceAdminService.validateApplyEligibility` / `validateInvoiceFields`) | | 100502 | 5 秒幂等窗口内重复提交 | | **100503(本次新增)** | 抢锁等待超过 3 秒(与 §7 admin 申请共用同一把 `order-invoice:apply` 锁),**可重试** | #### 业务边界 - 幂等:`@Idempotent` 5 秒窗口,口径同 §7。 - 抢锁失败(100503)时零写入。 - `requestedBy` 落库为用户 ID 字符串(区别于 admin 端落真实姓名),字段本身不在本次改动范围。 - 老数据兼容:无字段变化。 --- ## 四、契约约束与正确调用方式(接口类必写) > 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。 ### 正确 / 错误的客户端处理方式 本次不涉及请求体字段的互斥/联动规则变化,契约约束集中在"如何处理新失败码": | 场景 | 处理方式 | |------|----------| | ✅ 收到 `code=100503` | 提示"另一个操作正在进行,请稍后重试",**允许用户重试**,不当系统错误上报、不静默吞掉 | | ✅ 判断成功/失败 | 一律读响应体 `code`(`===200` 才算成功),不能只看 HTTP 状态码 | | ❌ 只看 HTTP 状态码 | `100503` 场景下 HTTP 仍是 200,只看状态码会把失败误判为成功,界面会"假装成功"但后端实际零写入 | | ❌ 把 `100503` 当致命错误弹全屏报错 | 应视为可重试的低频并发退避,不要引导用户联系客服或上报崩溃监控 | ### 三组锁各自的互斥范围(不要混淆) | 锁 name | 互斥范围 | 不在范围内 | |---------|----------|------------| | `traveler:delete:order` | §1 admin 删除 / §2 mp 删除 / §3 internal Feign 删除,三者对**同一 orderId** | 出行人的新增/编辑(属于 `traveler:batch-edit` 组) | | `traveler:batch-edit` | §4 admin 批量编辑 / §5 订单调整提交(仅含出行人变更时)/ §6 mp 出行人补全,三者对**同一 orderId** | 出行人的删除(属于 `traveler:delete:order` 组);§5 的非出行人子域(改期/行程/住宿/用车)与本锁无关 | | `order-invoice:apply` | §7 admin 申请 / §8 mp 申请,两者对**同一 orderId** | 「完成开票」`issueInvoice`、「重新上传」`reuploadInvoice`、「申请重开」`reissueInvoice`(那三者共用**另一把**锁 `order-invoice:issue`,已在 PR #8043 / changelog `20_7980_发票签发重传重开三写口补同一把锁新增100503冲突码触达C端-修改接口-管理后台.md` 中处理,与本文档不是同一组) | ### 幂等与并发冲突的区别 - 请求体**逐字节相同**且在幂等窗口内重复提交 → 幂等拦截,返回 `100502`(请勿重复提交),这是**另一个已存在的错误码**,本次不变。 - 请求体**不同**或已过幂等窗口,但撞上组内另一写口正在执行的同订单请求 → 本次新增的 `100503`。 --- ## 五、数据库行为(涉及写操作时必写) 本次不改变任何一次**成功**写操作实际写入的行数或内容——8 个端点各自原有的写入逻辑(软删/桥接表级联/批量 upsert/发票 INSERT)逐字节不变。变化的只是"谁能在同一时刻对同一 orderId 执行本组写入": | 组 | 改动前 | 改动后 | |----|--------|--------| | `traveler:delete:order` | admin/mp/internal 各持一把锁(internal 因自调用问题实际从未持锁),可并发互相踩踏「最后 1 位成人」内存计数 | 三者共享同一把锁,串行执行 | | `traveler:batch-edit` | admin/adjustment/mp 各持一把锁,可并发各读旧快照后各自写库 | 三者共享同一把锁,串行执行 | | `order-invoice:apply` | admin/mp 各持一把锁,可并发各读 `existsActive` 旧快照后各插一条申请 | 两者共享同一把锁,串行执行,一单一票才真正生效 | | 抢锁失败时是否有部分写入 | N/A(此前不会因锁而失败) | 否,零写入(`@Lock4j` 方法级环绕拦截,拿不到锁直接抛异常) | --- ## 六、边界行为 - 未登录 → 401(网关拦截) - 订单不存在/不属于当前用户 → 581102/581110/581122/581470(各端点适用情况见上) - 出行人删除相关业务闸口(已签合同/最后成人)→ 581106/581107,不受本次改动影响 - 出行人批量编辑校验矩阵(12301/格式/配额)→ 581101/581103/581104/581112-581114/581118/581119/581136,不受本次改动影响 - mp 端状态门禁(订单确认后禁止自助改动)→ 581145,不受本次改动影响 - 发票一单一票/状态门槛 → 581511/581524,不受本次改动影响 - **抢锁超时(本次新增)→ 100503,HTTP 200,可重试,零写入** - 幂等窗口内重复提交(请求体相同)→ 100502,与本次改动无关,行为不变 - 老数据兼容:本次不涉及字段增删,存量数据无需迁移 --- ## 六.5、枚举 / 数据字典(接口出现枚举时必写) ### 出行人性别(gender,出现于 §4/§6) **所属字段**: `travelers[].gender` | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `1` | 男 | - | | `2` | 女 | - | | `0` | 未知 | - | ### 发票类型(invoiceType,出现于 §7/§8) **所属字段**: `invoiceType` | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `VAT_NORMAL` | 增值税普通发票 | 默认场景 | | `VAT_SPECIAL` | 增值税专用发票 | 要求 `titleType=COMPANY`,且税号/银行/注册信息 5 字段全必填 | ### 发票抬头类型(titleType,出现于 §7/§8) **所属字段**: `titleType` | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `COMPANY` | 单位 | 抬头为公司名,`taxNo` 必填 | | `PERSONAL` | 个人 | 抬头为个人姓名,`taxNo` 非必填 | ### 发票状态(status,出现于 §7/§8 出参) **所属字段**: `AdminInvoiceApplyRespVO.status` / `MpInvoiceApplyRespVO.status` | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `REQUESTED` | 待开票 | 申请成功后的初始状态(admin/mp 两端申请落库值一致,均为 `REQUESTED`) | ## 六.6、修改前后对比(修改/删除类接口必写,新增跳过) ### 字段级对比 | 字段 | 改前 | 改后 | |------|------|------| | (无)| 8 个接口的请求字段、响应字段逐字节不变 | 同左 | ### 行为级对比 | 行为 | 改前 | 改后 | |------|------|------| | `traveler:delete:order` 组三入口并发 | 各持一把锁(internal 一支因自调用问题实际从未持锁),可并发破坏「最后 1 位成人」计数 | 三者共用同一把锁,串行执行 | | `traveler:batch-edit` 组三入口并发 | 各持一把锁,可并发各按旧快照写库 | 三者共用同一把锁,串行执行 | | `order-invoice:apply` 组两入口并发 | 各持一把锁,一单一票检查可被并发穿透 | 两者共用同一把锁,串行执行 | | 抢锁失败的响应 | 不存在这条路径 | 路径真实存在(返回 100503,HTTP 200,业务失败,可重试);本次改动未部署测试服,无实测触发频率数据 | | 请求/响应字段、既有业务错误码 | 不变 | 不变 | ## 六.7、影响评估(修改/删除类必写) - **是否破坏向后兼容**: 否——不改字段、不改既有错误码语义;但**新增了一条此前不存在的失败路径**(100503),且该路径会传导至小程序 C 端。 - **前端是否必须同步上线**: 建议同步——尤其是小程序 C 端出行人补全/删除、发票申请三个入口,命中该分支时若前端无兜底会把失败误判为成功(HTTP 200)。**触发频率本文档未做任何测试服实测**,不可援引本工单其他 changelog(fleet 价格日历 #8035、发票签发 #8043)测试服实测出的"频率极低"结论——那是各自独立测量的结果,本组三把锁粒度均为单订单,与被测的价格日历/发票签发粒度不同,机制相同不代表频率相同。 - **前端 workaround 清理点**: 无(本次是新增失败路径,不是清理旧 workaround)。 ## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面) - **仅影响**: 上述 8 个端点,抢锁失败时的响应体 - **零影响**: - 出行人列表查询(`GET /v3/admin/order/{id}/traveler/list`)、出行人校验(`GET .../traveler/validate`) - 单个出行人新增(`POST /v3/admin/order/{id}/traveler/add`,走另一把独立锁 `order:traveler:add:{id}`,本次未涉及) - 大交通批次相关接口 - 发票的「完成开票」「重新上传」「申请重开」三个写口(属于另一把锁 `order-invoice:issue`,已在 #8043 处理,非本文档范围) - 发票详情/列表/下载/企业信息自动回填等只读接口 - `order-ins:`(保险)/ `material:tag:name:`(resource)/ `fleet:msg-tpl:default:`(fleet)三组,本轮判定不修,行为未变 - 8 个端点的请求字段、响应字段结构 - 既有业务错误码的触发条件与文案 - 幂等拦截(100502)的行为 - 单个请求在**无并发**时的行为(延迟、返回值、写入内容全部不变) - hl-gateway 路由配置——8 个端点路径/方法零变化 --- ## 八、测试环境已验证 **部署读数(2026-09-20 23:24)**:hl-order-service-v3 滚动更新两实例,部署前 `c35251b07` → 部署后 `ba8aab3ab`,8186 与 8086 各 12 秒内起监听,`deploy-backend.sh` 报 `rolling deploy complete`。`git merge-base --is-ancestor 43c53c4c9 ba8aab3ab` 返回 **ANCESTOR_YES**。 🔴 本次**未在测试服做功能调用取证,这是有意为之而不是遗漏**:本组端点是删除出行人与批量改出行人,调用它们会**真实删改订单下的出行人行**;测试服订单数据被多个会话共用,删除**撤不回来**,副作用会落在一个不知情的人头上。 互斥本身由**落库级并发 IT** `Lock4jSharedNameConcurrencyIT` 覆盖(3 条用例,含专为自调用入口 `deleteByInternal` 单写的一条),不变量是「订单至少留 1 位成人」;另有 `Lock4jSharedNameContractTest` 按 SpEL 反查全类、钉死三组的成员集合,**能抓住日后新加的第 N 个方法**。 ⚠️ **`100503` 在测试服上一次都没有触发过**——不是「测过、不会触发」,是**没测**。 --- ## 十、相关文档 - 关联 Issue: [wx/HL#7980](https://git.1814.love:8443/wx/HL/issues/7980)(AC-7) - 关联 PR: [wx/HL#8049](https://git.1814.love:8443/wx/HL/pulls/8049) - 相关(另一组锁,非本文档范围): `changelogs-v2/2026-09/20_7980_发票签发重传重开三写口补同一把锁新增100503冲突码触达C端-修改接口-管理后台.md`(PR #8043,`order-invoice:issue` 锁) ## 关联 / 联系人 ### 链接 - **Issue**: [#7980](https://git.1814.love:8443/wx/HL/issues/7980) - **PR**: [#8049](https://git.1814.love:8443/wx/HL/pulls/8049) - **Merge commit**: [43c53c4c9](https://git.1814.love:8443/wx/HL/commit/43c53c4c9)(合并后回填,backend_status 待部署后翻转) ### 联系人 - **后端负责人**: @wx