文件
hl-api-changelog/changelogs-v2/2026-09/20_7980_出行人删改与发票申请三组写口补同名锁新增100503冲突码触达C端-修改接口-管理后台.md
T
2026-09-20 23:35:03 +08:00

46 KiB
原始文件 Blame 文件历史

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 键,于是每组内的多个方法各持一把互不相干的锁,可以并发互相踩踏:
    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<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 锁),可重试

业务边界

  • 幂等:@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<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 锁),可重试

业务边界

  • 幂等:@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<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 锁),可重试

业务边界

  • 幂等:@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<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 锁),可重试

业务边界

  • 幂等:@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<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 锁),可重试

业务边界

  • 幂等:@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(AC-7)
  • 关联 PR: wx/HL#8049
  • 相关(另一组锁,非本文档范围): changelogs-v2/2026-09/20_7980_发票签发重传重开三写口补同一把锁新增100503冲突码触达C端-修改接口-管理后台.md(PR #8043,order-invoice:issue 锁)

关联 / 联系人

链接

  • Issue: #7980
  • PR: #8049
  • Merge commit: 43c53c4c9(合并后回填,backend_status 待部署后翻转)

联系人

  • 后端负责人: @wx