--- schema: "hl-changelog/v2" ticket: "8629" title: "出行人/大交通批次单条新增:幂等键补身份段,同订单连续录入不同对象不再被误拦" consumer: "admin" author: "wx(GIT)" change_type: "修改接口" backend_status: "deployed" gateway_status: "not_required" frontend_status: "not_required" frontend_owner: "" frontend_ref: "" target_release: "" verified_at: "" status_note: "hl-order-service-v3 dev-v3 提交 91a52ba46c;两个端点的 @Idempotent 幂等键从「仅订单 ID」改为「订单 ID + 出行人/批次身份摘要」,请求体与响应体结构均未变。测试服验证:同订单并发提交两个不同出行人(间隔 150ms)均返回 200 各自创建成功;同订单并发提交两次完全相同的出行人(间隔 150ms)后一条返回 code=100502。;前端实证:hl-admin 两表单零幂等 workaround,不持有不拼接幂等键,结构零变化判 not_required(hl-admin sync-log 2026-10-01)" updated_at: "2026-09-30" base: "dev-v3" --- # 出行人/大交通批次单条新增:幂等键补身份段,同订单连续录入不同对象不再被误拦 > **存放目录**: 二期(order-v3/fleet)→ `changelogs-v2/2026-09/` > > **服务**: hl-order-service-v3 > **PR**: [#8637](https://git.1814.love:8443/wx/HL/pulls/8637) > **Issue**: [#8629](https://git.1814.love:8443/wx/HL/issues/8629) > **日期**: 2026-09-30 > **影响范围**: 管理后台订单详情页「出行人」「大交通」两个单条新增表单 --- ## ⚠️ 关键变化 **改前**:`POST /v3/admin/order/{id}/traveler/add` 与 `POST /v3/admin/order/{id}/transport-plan/add` 的幂等键只拼订单 ID(`#id`),3 秒窗口内**同订单任意两次提交**都会被判定为重复请求而拦截——哪怕两次提交的是完全不同的两个人、或完全不同的两个大交通批次。定制师连续为一家人逐个录入出行人、或逐个补录到达/返程批次时,第二个请求大概率落在 3 秒窗口内,被误拦为「同一出行人/批次刚已提交,请勿重复提交」,且该提示恒为假(首次请求早已成功结束,不是「处理中」)。 **改后**:幂等键改为「订单 ID + 该对象的身份摘要」(出行人:姓名+证件号+出生日期+手机号;大交通批次:方向+交通类型+车次/航班号+出发时间+自驾时段+关联出行人集合,排序后取 SHA-256)。**身份任一字段不同即视为不同对象,两次提交各自成功,前端无需人为在两次提交间插入延时**;只有身份完全相同的重复提交才会在 3 秒窗口内被拦。请求体、响应体结构均未变,也未新增/删除任何字段。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 单个出行人新增 | POST | `/v3/admin/order/{id}/traveler/add` | 幂等键收窄 | 幂等键加入出行人身份摘要,不同人不再互相拦截 | | 2 | 大交通批次新增 | POST | `/v3/admin/order/{id}/transport-plan/add` | 幂等键收窄 | 幂等键加入批次身份摘要,不同批次不再互相拦截 | --- ## 三、接口详情 ### 1. 单个出行人新增 `POST /v3/admin/order/{id}/traveler/add` **VO**: `TravelerCreateReqVO → TravelerVO` #### 使用场景 管理后台订单详情页「出行人」页签,定制师为已创建的订单逐个补录出行人档案时调用;与批量编辑接口(`/traveler/batch-edit`)并列存在,用于单个补录/追加场景,例如客户临时增加一名随行儿童。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | id | Path | Long | ✅ | - | 订单 ID | | name | Body | String | - | - | 姓名(可后填) | | gender | Body | String | - | 1=男/2=女/0=未知 | 性别 | | birthday | Body | LocalDate | ✅ | 不得晚于今天 | 出生日期,后端按年龄分段自动派生 `travelerType` | | idType | Body | String | - | 取值见数据字典 `id_card_type` | 证件类型 | | idNo | Body | String | - | - | 证件号(明文传,DB 加密) | | nationality | Body | String | - | - | 国籍(默认中国) | | race | Body | String | - | - | 民族(默认汉族) | | phone | Body | String | - | - | 出行人手机(明文传,DB 加密) | | emergencyContact | Body | String | - | - | 紧急联系人姓名 | | emergencyPhone | Body | String | - | - | 紧急联系人电话(明文传) | | roomGroupNo | Body | Integer | - | - | 同住分组号 | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | id | Long | 出行人 ID | | orderId | Long | 订单 ID | | teamNo | String | 团号 | | travelerType | String | 出行人类型:ADULT/CHILD/YOUNG_CHILD/BABY | | travelerTypeName | String | 出行人类型中文名(字典优先,枚举 label 兜底) | | name | String | 姓名 | | gender | String | 性别:1=男/2=女/0=未知 | | birthday | LocalDate | 出生日期 | | idType | String | 证件类型 | | idTypeName | String | 证件类型中文名(字典优先,枚举 label 兜底) | | idCardMasked | String | 证件号脱敏值(前3后4,中间星号,#2894) | | idProvinceCode | String | 身份证省级行政区代码;非大陆证件或无法识别为 null | | idProvinceName | String | 身份证省级行政区名称 | | nativePlace | String | 所属地(省+地级市,#5642);非大陆证件或未命中为 null | | nationality | String | 国籍 | | race | String | 民族 | | phoneMasked | String | 出行人手机脱敏值 | | emergencyContact | String | 紧急联系人姓名 | | emergencyPhoneMasked | String | 紧急联系人电话脱敏值 | | roomGroupNo | Integer | 同住分组号 | | profileStatus | String | 资料完善状态:PENDING/COMPLETED | | transportPlanIds | List\ | 关联大交通批次 ID 列表 | #### 请求示例 ```json { "name": "王小明", "gender": "1", "birthday": "2018-06-20", "idType": "ID_CARD", "idNo": "220103201806201234", "nationality": "中国", "race": "汉族", "phone": "13812342046", "emergencyContact": "王大明", "emergencyPhone": "13988888888", "roomGroupNo": 1 } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "data": { "id": 70123456789012, "orderId": 60123456789012, "teamNo": "26-0480", "travelerType": "ADULT", "travelerTypeName": "成人", "name": "张三", "gender": "1", "birthday": "1985-08-12", "idType": "ID_CARD", "idTypeName": "身份证", "idCardMasked": "220***********1234", "idProvinceCode": "22", "idProvinceName": "吉林省", "nativePlace": "内蒙古呼伦贝尔市", "nationality": "中国", "race": "汉族", "phoneMasked": "138****2046", "emergencyContact": "李四", "emergencyPhoneMasked": "139****8888", "roomGroupNo": 1, "profileStatus": "COMPLETED", "transportPlanIds": [] }, "traceId": null, "success": true } ``` #### 空数据 / 降级响应 本接口是单条创建接口,成功路径必返回完整的单个对象,不存在「空列表」场景。`travelerTypeName` / `idTypeName` 的中文名解析是「字典优先,枚举 label 兜底」——字典服务不可用时回落到枚举自带中文 label,不会返回 `null`(该兜底策略是既有行为,非本次改动)。 #### 错误响应 ```json { "code": 100502, "message": "同一出行人刚已提交,请勿重复提交", "data": null, "traceId": null, "success": false } ``` #### 业务边界 - 3 秒幂等窗口内,仅当出行人身份(姓名+证件号+出生日期+手机号)与前一次提交**完全相同**时才会被拦为 `code=100502`;姓名/证件号/出生日期/手机号任一不同即视为不同的人,两次提交各自成功,前端无需人为在两次提交间插入延时。 - 幂等身份摘要取 SHA-256(64 位小写 hex),不改变请求体字段本身;前端提交的仍是原始明文字段。 - 同订单另有 30 秒 `@Lock4j` 互斥锁(本次未变),用于防止与批量编辑接口(`/traveler/batch-edit`)并发写导致人数计数错乱;锁只管互斥,幂等键只管拦「同一次提交的重放」,两者不可互相替代。 - 成功响应 `code` 固定为 **200**(非 0);若前端用 `code === 0` 判断成功,会把这次成功误判为失败。 - 未登录 → 网关层拦截,返回 401 信封;`birthday` 缺失 → HTTP 200 + `code: 400`(`@NotNull`/`@PastOrPresent` 校验失败)。 --- ### 2. 大交通批次新增 `POST /v3/admin/order/{id}/transport-plan/add` **VO**: `TransportPlanReqVO → TransportPlanVO` #### 使用场景 管理后台订单详情页「大交通」页签,定制师为订单逐个新增到达/返程批次时调用(每个批次至少关联 1 名出行人);与批量替换接口(`/transport-plan/batch`)并列存在,用于「多批到达/返程」场景下逐批补录。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | id | Path | Long | ✅ | - | 订单 ID | | direction | Body | String | ✅ | ARRIVAL / DEPARTURE | 方向 | | transportType | Body | String | ✅ | FLIGHT / TRAIN / SELF_DRIVE | 交通类型 | | transportNo | Body | String | - | SELF_DRIVE 时为空 | 航班号/车次号 | | carrier | Body | String | - | - | 航司/铁路公司 | | departStation | Body | String | - | - | 出发站 | | arriveStation | Body | String | - | - | 到达站 | | departTime | Body | LocalDateTime | - | FLIGHT/TRAIN 必填 | 出发时间 | | arriveTime | Body | LocalDateTime | - | FLIGHT/TRAIN 必填 | 到达时间 | | selfDrivePeriod | Body | String | - | MORNING/AFTERNOON/EVENING,仅 SELF_DRIVE | 自驾时段 | | selfDriveEta | Body | LocalDateTime | - | 仅 SELF_DRIVE 可选 | 自驾预计到达时间 | | travelerIds | Body | List\ | ✅ | 至少 1 个 | 关联出行人 ID | | pickupRequired | Body | Boolean | - | 新增缺省 true | 是否需要接送 | | pickupRemark | Body | String | - | - | 接送备注 | | remark | Body | String | - | ≤500 字 | 备注 | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | id | String | 批次 ID(`Long` 按字符串序列化) | | orderId | String | 订单 ID(`Long` 按字符串序列化) | | teamNo | String | 团号 | | direction | String | 方向:ARRIVAL/DEPARTURE | | directionLabel | String | 方向中文标签 | | mode | String | 模式:TOGETHER/SEPARATE;单条新增固定为 TOGETHER | | modeLabel | String | 模式中文标签 | | transportType | String | 交通类型:FLIGHT/TRAIN/SELF_DRIVE | | transportTypeLabel | String | 交通类型中文标签 | | transportNo | String | 航班号/车次号 | | carrier | String | 航司/铁路公司 | | departStation | String | 出发站 | | arriveStation | String | 到达站 | | departTime | LocalDateTime | 出发时间 | | arriveTime | LocalDateTime | 到达时间 | | selfDrivePeriod | String | 自驾时段 | | selfDrivePeriodLabel | String | 自驾时段中文标签 | | selfDriveEta | LocalDateTime | 自驾预计到达时间 | | pickupRequired | Boolean | 是否需要接送 | | pickupRemark | String | 接送备注 | | travelers | List\ | 关联出行人(id 字符串化 + name + travelerType + travelerTypeName) | | remark | String | 备注 | | creatorType | String | 创建来源:USER/ADMIN | | creatorTypeName | String | 创建来源中文名 | | createTime | LocalDateTime | 创建时间 | | updateTime | LocalDateTime | 更新时间 | #### 请求示例 ```json { "direction": "ARRIVAL", "transportType": "FLIGHT", "transportNo": "CA1234", "carrier": "中国国际航空", "departStation": "北京首都T3", "arriveStation": "长春龙嘉", "departTime": "2026-06-01T08:30:00", "arriveTime": "2026-06-01T10:15:00", "travelerIds": [70123456789012, 70123456789013], "pickupRequired": true, "pickupRemark": "需在 T3 出口举牌接机", "remark": "需要接机举牌" } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "data": { "id": "80012345", "orderId": "60123456789012", "teamNo": "26-0480", "direction": "ARRIVAL", "directionLabel": "到达", "mode": "TOGETHER", "modeLabel": "一起到达", "transportType": "FLIGHT", "transportTypeLabel": "飞机", "transportNo": "CA1234", "carrier": "中国国际航空", "departStation": "北京首都T3", "arriveStation": "长春龙嘉", "departTime": "2026-06-01T08:30:00", "arriveTime": "2026-06-01T10:15:00", "selfDrivePeriod": null, "selfDrivePeriodLabel": null, "selfDriveEta": null, "pickupRequired": true, "pickupRemark": "需在 T3 出口举牌接机", "travelers": [ { "id": "70123456789012", "name": "张三", "travelerType": "ADULT", "travelerTypeName": "成人" } ], "remark": "需要接机举牌", "creatorType": "ADMIN", "creatorTypeName": "定制师代录", "createTime": "2026-07-01T10:00:00", "updateTime": "2026-07-01T10:30:00" }, "traceId": null, "success": true } ``` #### 空数据 / 降级响应 本接口是单条创建接口,成功路径必返回完整的单个对象,不存在「空列表」场景。`travelers[].travelerTypeName` 同样是「字典优先,枚举 label 兜底」,字典服务不可用时回落枚举 label,不返回 `null`(既有行为,非本次改动)。 #### 错误响应 ```json { "code": 100502, "message": "同一大交通批次刚已提交,请勿重复提交", "data": null, "traceId": null, "success": false } ``` #### 业务边界 - 3 秒幂等窗口内,仅当批次身份(方向+交通类型+车次/航班号+出发时间+自驾时段+关联出行人集合)与前一次提交**完全相同**时才会被拦为 `code=100502`;任一字段不同即视为不同批次,两次提交各自成功。 - `travelerIds` 参与身份摘要前会先排序去重:前端把同一批 ID 换个顺序重新提交,仍会命中同一幂等键(业务上视为同一次提交的重放)。 - SELF_DRIVE 场景 `transportNo` 为空、`departTime` 也非必填,身份摘要因此额外纳入 `transportType`/`selfDrivePeriod`/`travelerIds`,避免两批不同自驾到达被误判为同一批。 - 同订单另有 30 秒 `@Lock4j` 互斥锁(本次未变),用于防止 admin 多端并发新增同方向 plan;锁与幂等键职责不同、不可互相替代。 - 成功响应 `code` 固定为 **200**(非 0);若前端用 `code === 0` 判断成功,会把这次成功误判为失败。 - 未登录 → 网关层拦截,返回 401 信封;`direction`/`transportType` 缺失或 `travelerIds` 为空 → HTTP 200 + `code: 400`。 --- ## 四、契约约束与正确调用方式 > 本节只写后端接受/拒绝 payload 的规则,不写 UI 渲染建议。 ### ✅ 正确 / ❌ 错误 payload 对照 | 场景 | 结果 | |------|------| | ✅ 同订单连续新增两个不同出行人(`name`/`idNo`/`birthday`/`phone` 任一不同) | 两次请求均返回 200,各自创建成功,无需人为延时 | | ✅ 同订单连续新增两个不同大交通批次(身份字段任一不同) | 两次请求均返回 200,各自创建成功 | | ❌ 3 秒内重复提交身份字段完全相同的出行人 | 第二次返回 `code=100502`,零写入 | | ❌ 3 秒内重复提交身份字段完全相同的大交通批次 | 第二次返回 `code=100502`,零写入 | ### 切换状态时的必要动作 两个接口均为单条创建接口,不涉及状态切换;调用前无需额外前置动作。 --- ## 五、数据库行为 | 场景 | 写入行为 | |------|----------| | 同订单连续提交两个不同出行人 | 各自插入 1 行 `order_traveler`;改动前,第二个请求会被幂等键拦截,零写入 | | 同订单 3 秒内重复提交同一出行人(身份完全相同) | 只有首次请求落 1 行;重复请求被拦截,零写入(改动前后一致) | | 同订单连续提交两个不同大交通批次 | 各自插入 1 行 `order_transport_plan` + N 行桥接表 `order_transport_plan_traveler`;改动前,第二个请求会被拦截,零写入 | | 同订单 3 秒内重复提交同一批次(身份完全相同) | 只有首次请求写入;重复请求被拦截,零写入(改动前后一致) | --- ## 六、边界行为 - 未登录 → 401(网关拦截) - 校验失败(`birthday` 缺失、`direction`/`transportType` 非法、`travelerIds` 为空等) → HTTP 200 + `code: 400` - 3 秒窗口内同身份重复提交 → HTTP 200 + `code: 100502`,零写入 - 下游字典服务(`travelerTypeName`/`idTypeName`/`transportTypeLabel` 等中文名解析)降级 → 回落枚举自带中文 label,不返回 null、不阻断主流程(既有行为,非本次改动) --- ## 六.6、修改前后对比 ### 字段级对比 | 字段 | 改前 | 改后 | |------|------|------| | (无) | 请求体、响应体字段结构均未变 | 同左 | ### 行为级对比 | 行为 | 改前 | 改后 | |------|------|------| | 幂等键组成 | `#id`(仅订单 ID) | `#id + ':' + 身份摘要`(订单 ID + SHA-256 身份摘要) | | 同订单连续提交两个不同对象(3 秒内) | 第二次被拦为 `code=100502`,零写入,且提示「处理中」恒为假 | 两次均成功,各自写入 1 条记录 | | 3 秒内重复提交同一对象(身份完全相同) | 拦截 | 拦截(无变化) | | 错误提示文案 | 「同一出行人刚已提交,请勿重复提交」/「同一大交通批次刚已提交,请勿重复提交」(改动前后文案一致,仅拦截范围变窄) | 同左 | --- ## 六.7、影响评估 - **是否破坏向后兼容**: 否——请求体、响应体字段结构未变,只是原本被误拦的场景(连续提交不同对象)现在能成功,属于放宽约束 - **前端是否必须同步上线**: 否——若前端此前为规避误拦而在两次提交之间人为加了延时或做了排队逻辑,那段代码可以撤掉,但不撤也不会出错 - **前端 workaround 清理点**: 若前端曾针对「连续录入第二个出行人/批次报 100502」做过特殊重试或提示遮蔽逻辑,现在可以移除;不清理也不影响功能,只是不再需要 --- ## 七、不影响范围 - **仅影响**: `POST /v3/admin/order/{id}/traveler/add`、`POST /v3/admin/order/{id}/transport-plan/add` 两个端点的幂等拦截范围 - **零影响**: - 出行人批量编辑接口 `/traveler/batch-edit`(幂等键仍是 `#id`,未变) - 大交通批次编辑/软删/批量替换接口 `/transport-plan/{planId}/edit`、`/transport-plan/{planId}/delete`、`/transport-plan/batch`(幂等键均未变) - 出行人软删、信息校验、补全出行信息等其余出行人/大交通端点 - 请求体、响应体字段结构(未新增/删除/改类型任何字段) - 同订单 30 秒 `@Lock4j` 互斥锁行为 --- ## 八、测试环境已验证 测试服(hl-order-service-v3,dev-v3 提交 `91a52ba46c`)实测: ``` POST /v3/admin/order/{id}/traveler/add 同订单并发提交两个不同出行人(间隔 150ms) → 均 200,各自创建成功 ✓ POST /v3/admin/order/{id}/traveler/add 同订单并发提交两次完全相同的出行人(间隔 150ms) → 先 200,后一条 code=100502 ✓ ``` --- ## 十、相关文档 - 关联 Issue: [wx/HL#8629](https://git.1814.love:8443/wx/HL/issues/8629) - 关联 PR: [wx/HL#8637](https://git.1814.love:8443/wx/HL/pulls/8637) ## 关联 / 联系人 ### 链接 - **Issue**: [#8629](https://git.1814.love:8443/wx/HL/issues/8629) - **PR**: [#8637](https://git.1814.love:8443/wx/HL/pulls/8637) - **Merge commit**: [91a52ba46c](https://git.1814.love:8443/wx/HL/commit/91a52ba46c) ### 联系人 - **后端负责人**: @wx