46 KiB
schema, ticket, title, consumer, author, change_type, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
| schema | ticket | title | consumer | author | change_type | backend_status | gateway_status | frontend_status | frontend_owner | frontend_ref | target_release | verified_at | status_note | updated_at | base |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| hl-changelog/v2 | 7980 | 出行人删除/批量编辑与发票申请三组写口补同一把 @Lock4j name,新增可重试冲突码 100503(会传导至小程序 C 端) | multiple | wx(GIT) | 修改接口 | deployed | not_required | not_required | mmg | 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 消费,本仓不越权。 | 2026-09-20 | 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 键,于是每组内的多个方法各持一把互不相干的锁,可以并发互相踩踏:traveler:delete:order(3 处):TravelerService.deleteTraveler(admin)/mpDeleteTraveler(mp)/deleteByInternal(internal Feign,且它经this.deleteTraveler(...)自调用,不走 Spring 代理,之前一次都没触发过锁)。「最后 1 位成人禁删」是内存计数的 check-then-act,order_traveler无@Version、无行锁复读,没有第二道锁兜底——两个并发删除请求各读到「还有 2 个成人」的旧快照就会把成人删到 0。traveler:batch-edit(3 处):TravelerService.batchEditTravelers(admin 直接批量编辑)/batchEditTravelersForAdjustment(订单调整提交里的出行人 tab)/mpBatchEditTravelers(mp)。「基于内存合并的最终生效集合先校验后写库」同样是 check-then-act,没有第二道锁兜底。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<Boolean>
| 字段 | 类型 | 说明 |
|---|---|---|
| data | Boolean | 恒为 true(失败走异常,不会返回 false) |
请求示例
POST /v3/admin/order/1234567890123456789/traveler/70123456789012/delete
Authorization: Bearer {token}
响应示例
{ "code": 200, "message": "成功", "data": true, "success": true }
空数据 / 降级响应
本接口无「空数据」/降级概念(成功恒返回 true,失败一律抛业务异常)。
错误响应
{ "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<Void>
| 字段 | 类型 | 说明 |
|---|---|---|
| data | null | 无返回数据 |
请求示例
DELETE /mp/order/1234567890123456789/traveler/70123456789012
Authorization: Bearer {token}
(以上为小程序 C 端实际调用的公网地址;order-v3 侧 internal 路径见本节标题。)
响应示例
{ "code": 200, "message": "成功", "data": null, "success": true }
空数据 / 降级响应
本接口无「空数据」/降级概念。
错误响应
{ "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 锁),可重试 |
业务边界
- 幂等:
@Idempotent3 秒窗口(键=订单 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<Boolean>
| 字段 | 类型 | 说明 |
|---|---|---|
| data | Boolean | 恒为 true(委托 deleteTraveler,失败走异常) |
请求示例
DELETE /v3/internal/order/orders/1234567890123456789/travelers/70123456789012
(跨服务 Feign 调用,无鉴权头。)
响应示例
{ "code": 200, "message": "成功", "data": true, "success": true }
空数据 / 降级响应
本接口无「空数据」/降级概念,也无当前真实流量。
错误响应
{ "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<Item> | ✅ | @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<TravelerBatchEditRespVO>
| 字段 | 类型 | 说明 |
|---|---|---|
| createdCount | Integer | 本次新增的出行人数(id=null 行) |
| updatedCount | Integer | 本次更新的出行人数(id 非 null 行) |
| completedCount | Integer | 本次操作后变为 COMPLETED 的行数 |
| pendingCount | Integer | 仍为 PENDING 的行数 |
| allCompleted | Boolean | 该订单所有出行人是否已完善 |
请求示例
{
"travelers": [
{
"id": null,
"name": "张三",
"gender": "1",
"birthday": "1985-08-12",
"idType": "ID_CARD",
"idNo": "220103198508121234",
"nationality": "中国",
"race": "汉族",
"phone": "13812342046"
}
]
}
响应示例
{
"code": 200,
"message": "成功",
"data": { "createdCount": 1, "updatedCount": 0, "completedCount": 1, "pendingCount": 0, "allCompleted": true },
"success": true
}
空数据 / 降级响应
本接口无「空数据」概念(travelers 要求非空),无降级路径。
错误响应
{ "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 锁),可重试 |
业务边界
- 幂等:
@Idempotent3 秒窗口(键=订单 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<TravelerEditVO> | - | - | 新增出行人列表 |
| updates.people.travelers.update | Body | List<TravelerEditVO> | - | 含 id 字段 | 更新出行人列表 |
| updates.people.travelers.remove | Body | List<Long> | - | - | 删除出行人 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<AdjustmentSubmitRespVO>
| 字段 | 类型 | 说明 |
|---|---|---|
| success | Boolean | 提交是否成功(极简响应,失败统一走全局异常处理器返回 Result{code,message}) |
请求示例(仅出行人 tab 片段)
{
"updates": {
"people": {
"travelers": {
"add": [
{ "name": "李四", "idType": "ID_CARD", "idNo": "110101198801011234", "birthday": "1988-01-01" }
],
"remove": [70123456789013]
}
}
}
}
响应示例
{ "code": 200, "message": "成功", "data": { "success": true }, "success": true }
空数据 / 降级响应
updates 各子域均可为 null(按需填),但不能全 null(否则 AdjustmentErrorCode.ADJUST_NO_EFFECTIVE_CHANGE)。不含出行人变更时本次改动完全不生效,走原有逻辑。
错误响应
{ "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<Item> | ✅ | @NotEmpty,≤30 |
字段结构同 §4 |
出参 Result<TravelerBatchEditRespVO>
字段结构同 §4:
| 字段 | 类型 | 说明 |
|---|---|---|
| createdCount | Integer | 本次新增的出行人数(id=null 行) |
| updatedCount | Integer | 本次更新的出行人数(id 非 null 行) |
| completedCount | Integer | 本次操作后变为 COMPLETED 的行数 |
| pendingCount | Integer | 仍为 PENDING 的行数 |
| allCompleted | Boolean | 该订单所有出行人是否已完善 |
请求示例
{
"travelers": [
{ "id": null, "name": "王五", "idType": "ID_CARD", "idNo": "310101199001011234", "birthday": "1990-01-01" }
]
}
响应示例
{
"code": 200,
"message": "成功",
"data": { "createdCount": 1, "updatedCount": 0, "completedCount": 1, "pendingCount": 0, "allCompleted": true },
"success": true
}
空数据 / 降级响应
本接口无「空数据」概念(travelers 要求非空),无降级路径。
错误响应
{ "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 锁),可重试 |
业务边界
- 幂等:
@Idempotent3 秒窗口(键=订单 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 字符 | 注册电话(同上) |
| Body | String | ✅ | @Email,≤128 字符 |
收件邮箱 | |
| remark | Body | String | - | ≤500 字符 | 备注/货物或应税劳务名称 |
出参 Result<AdminInvoiceApplyRespVO>
| 字段 | 类型 | 说明 |
|---|---|---|
| id | String(雪花,序列化为字符串) | 发票 ID |
| orderId | String | 订单 ID |
| status | String | 发票状态(REQUESTED=待开票) |
| amount | BigDecimal | 开票金额(申请阶段为 null,开票时才计算写入) |
| requestedAt | LocalDateTime | 申请时间 |
| requestedBy | String | 申请人(定制师真实姓名) |
请求示例
{
"invoiceType": "VAT_NORMAL",
"titleType": "COMPANY",
"titleName": "上海呼籁旅行科技有限公司",
"taxNo": "91310115MA1K48XXXX",
"email": "finance@hulalv.com"
}
响应示例
{
"code": 200,
"message": "成功",
"data": {
"id": "2100889866400104450",
"orderId": "1234567890123456789",
"status": "REQUESTED",
"amount": null,
"requestedAt": "2026-09-20T14:30:25",
"requestedBy": "李定制"
},
"success": true
}
空数据 / 降级响应
本接口无「空数据」概念(成功恒返回完整响应),无降级路径。
错误响应
{ "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 锁),可重试 |
业务边界
- 幂等:
@Idempotent5 秒窗口,键含订单+发票类型+抬头名称——抬头或类型不同即不拦,替代不了这把锁(两个不同抬头的并发申请仍会分别通过幂等但撞上一单一票)。 - 抢锁失败(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 字符 | 注册电话 |
| Body | String | ✅ | @Email,≤128 字符 |
收件邮箱 | |
| applyReason | Body | String | - | ≤500 字符 | 申请说明 |
(userId 从 JWT 解析,前端不传。)
出参 Result<MpInvoiceApplyRespVO>
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long | 发票主键 |
| orderId | Long | 订单 ID |
| status | String | 状态:实际写入值为 REQUESTED(与 hl-mp-service 侧 VO 注释所写"固定 APPLIED"不一致,以 order-v3 源码 InvoiceMpService.mpApplyInvoice 实际赋值为准) |
| amount | BigDecimal | 申请金额(申请阶段为 null) |
| applyAt | LocalDateTime | 申请时间 |
| triggerPushLogIds | List<Long> | 触发的通知推送日志 ID 列表(当前恒为空数组) |
请求示例
{
"invoiceType": "VAT_NORMAL",
"titleType": "PERSONAL",
"titleName": "张三",
"email": "zhangsan@example.com"
}
响应示例
{
"code": 200,
"message": "成功",
"data": {
"id": 2100889866400104460,
"orderId": 1234567890123456789,
"status": "REQUESTED",
"amount": null,
"applyAt": "2026-09-20T14:35:10",
"triggerPushLogIds": []
},
"success": true
}
空数据 / 降级响应
本接口无「空数据」概念,无降级路径。
错误响应
{ "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 锁),可重试 |
业务边界
- 幂等:
@Idempotent5 秒窗口,口径同 §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(AC-7)
- 关联 PR: wx/HL#8049
- 相关(另一组锁,非本文档范围):
changelogs-v2/2026-09/20_7980_发票签发重传重开三写口补同一把锁新增100503冲突码触达C端-修改接口-管理后台.md(PR #8043,order-invoice:issue锁)
关联 / 联系人
链接
联系人
- 后端负责人: @wx