23 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 | 7532 | 团期出团通知书读写端点(GB-ADM-081 / 082) | admin | jw(GIT) | 新增接口 | deployed | verified | verified | mmg | 95518b50 | 2026-09-13 | 两个新端点。前端必接三件事:① expectedVersion 必填、首次传 0、收到 589585 必须重读再提交(不能直接重试);② releasable=false 时置灰「打印 / 存 PDF」;③ 正文是纯文本语义,后端不转义也不反转义,前端按纯文本渲染。另:新权限码 group-batch:docs 在 user-service 有 10 分钟缓存,上线后最多 10 分钟才对 ADMIN 生效。 前端 2026-09-13 已交付(hl-admin 95518b50):新建 groupBatchNotice.js(V3 baseURL 置空+雪花串透传)+7 例定向单测;新建 GroupBatchNoticeModal 全屏浮层左表单右纯文本预览,详情页「更多操作」常挂入口。落实红线:expectedVersion 首次传 0/此后取响应 version 禁自增;589585 重新 GET 读回再提交禁原样重试;releasable=false 只置灰打印/存PDF编辑保存可用;正文纯文本禁 v-html;589587 透后端 message;contacts 单串不拆。单测 7/7+checkpoint 13 项全绿。 | 2026-09-13 | dev-v3 |
order-v3: 团期出团通知书读写端点(GB-ADM-081 / 082)
服务: hl-order-service-v3 PR: #7577 Issue: #7532
⚠️ 关键变化
🔴 expectedVersion 是必填的乐观锁版本,收到 589585 必须重读再提交。 直接原样重试一定还是 589585。首次保存传 0,此后取上一次读/写响应里的 version。
🔴 正文是纯文本语义:后端不做 HTML 转义,也不做反转义。 请按纯文本渲染(textContent / Vue 的 {{ }}),不要 v-html。之所以不在后端转义:那只约束单次请求,挡不住「读出 → 只改集合时间 → 其他字段原样提交」这种普通编辑——A&B 会逐轮变成 A&B、A&B,用户第一次读回就看到编码串。
🔴 releasable=false 时请置灰「打印 / 存 PDF」。 阶段门只卡下发不卡编辑:招募期就可以起草和保存,但不该发给客人。
🔴 保存是整体覆盖,不是增量。 9 个内容字段必须每次带齐,服务端不做字段级合并。编辑前先 GET 读回全量。
🔴 新权限码 group-batch:docs 有最多 10 分钟的生效延迟。 权限码在 user-service 有 10 分钟 Redis 缓存,上线后 ADMIN 可能先看到 589507、约 10 分钟后自动变正常。这不是漏配,请勿在这段窗口内报障。当前授权范围:ADMIN / SUPER_ADMIN(FINANCE 未授)。
一、背景
出团前运营要给客人发一份「出团通知书」:集合时间地点、领队是谁、坐什么车、含哪些服务、要带什么、出事找谁。
改前这份东西完全在线下 Word 里做,团期系统里一个字段都没有,每次出团都要人肉从四五个页面抄数据,抄错就是客人第二天站错地方。
本单给它加了读、写两个端点。读端点在未保存过时按团期现有数据即时拼一份草稿返回(不落库),运营在草稿上改完再保存。
二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 读出团通知书 | GET | /v3/admin/order/group-batch/{groupBatchId}/docs/notice |
新增 | 未保存过时返回按团期数据拼的草稿,不落库 |
| 2 | 保存出团通知书 | PUT | /v3/admin/order/group-batch/{groupBatchId}/docs/notice |
新增 | 整体覆盖保存,version 乐观锁 |
三、接口详情
1. 读出团通知书 GET /v3/admin/order/group-batch/{groupBatchId}/docs/notice
VO: GroupBatchNoticeRespVO
使用场景
团期详情 → 底部操作条「打印 / 导出 ▾」→「生成出团通知书」→ 全屏浮层。打开浮层时调一次。
从未保存过 → 返回按团期现状拼的草稿(saved=false);已保存过 → 返回库内内容(saved=true)。两种情况都附带 defaults 实时默认值。
入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| groupBatchId | Path | Long | ✅ | 团期主订单 ID | 是运营团期主键 order_group_batch.group_batch_id,不是产品侧排期 ID productBatchId |
出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| title | String | 通知书标题 |
| greeting | String | 问候语 |
| meetTime | String | 集合时间。展示文案,不是时间类型,服务端不解析格式 |
| meetPlace | String | 集合地点。无结构化来源,默认恒为空串,由运营填 |
| leader | String | 领队姓名与介绍。默认值来自团期人员配置的导游位(GUIDE 与 LEADER 并收),按 sortOrder 升序、、 连接 |
| bus | String | 车辆信息(车型 + 车牌)。默认值来自团内子订单的行程用车快照,已去重 |
| contacts | String | 联系方式。默认值 = 首位领队姓名 + 脱敏手机号 |
| service | String | 服务包含说明。无结构化来源,默认恒为空串 |
| bring | String | 携带物品清单。无结构化来源,默认恒为空串 |
| saved | Boolean | false = 本次是即时生成的草稿、尚未落库;true = 取自库内 |
| releasable | Boolean | 是否允许下发给客户。服务端计算,客户端传值忽略。false 时请置灰「打印 / 存 PDF」 |
| version | Integer | 乐观锁版本。saved=false 时恒为 0。保存请求的 expectedVersion 取自这里 |
| updateTime | String | 最后保存时间,ISO-8601 秒精度(2026-09-12T11:36:51)。saved=false 时为 null |
| defaults | Object | 实时默认值快照,含上面 9 个内容字段。saved=false 时与正文完全一致;saved=true 时供前端比对提示「领队 / 用车已变更,是否更新」 |
请求示例
GET /v3/admin/order/group-batch/2096495107078328322/docs/notice
Authorization: Bearer <admin token>
响应示例
未保存过(草稿):
{
"code": 200,
"message": "成功",
"data": {
"title": "测试小蒙马-多档-固定金额 · 出团通知书",
"greeting": "亲爱的团友,欢迎参加本次行程!",
"meetTime": "2026-12-20 08:30",
"meetPlace": "",
"leader": "刘大山、萨仁高娃",
"bus": "mpv 蒙A-G8888",
"contacts": "刘大山 138****1005",
"service": "",
"bring": "",
"saved": false,
"releasable": false,
"version": 0,
"updateTime": null,
"defaults": {
"title": "测试小蒙马-多档-固定金额 · 出团通知书",
"greeting": "亲爱的团友,欢迎参加本次行程!",
"meetTime": "2026-12-20 08:30",
"meetPlace": "",
"leader": "刘大山、萨仁高娃",
"bus": "mpv 蒙A-G8888",
"contacts": "刘大山 138****1005",
"service": "",
"bring": ""
}
},
"success": true
}
空数据 / 降级响应
默认值是「尽力而为」,任何一个来源取不到都退化为空串,接口照常 200、不报错:
- 团期未配人员 →
leader/contacts为空串 - 团期无子订单 / 未配车 →
bus为空串 - 团期出发日为空 →
meetTime为空串(不会拼出null 08:30这种串) - 某一户的用车快照数据异常 → 只跳过那一户,
bus用其余户拼出来;整张通知书照常返回
九个内容字段永远是字符串,不会是 null,前端不必判空指针。
错误响应
| 码 | 符号 | 触发 |
|---|---|---|
| 589500 | GROUP_BATCH_NOT_FOUND |
团期不存在 / 已软删除 |
| 589507 | GROUP_BATCH_PERMISSION_DENIED |
无 group-batch:docs 权限,或请求未经网关鉴权 |
{
"code": 589507,
"message": "无操作权限(非团期管理员 / 非本定制师名下)",
"data": null,
"traceId": null,
"success": false
}
业务边界
- 读接口不落库:
saved=false的草稿只是即时拼出来的,不调这个接口再调保存,库里不会有任何行。 - 保存后正文就冻结了:之后换了领队、改了配车,已保存的
leader/bus不会自动刷新。这是对客定稿文档应有的行为——自动变更比不变更更危险(客人手里的版本会和系统里的对不上)。想提示用户,就拿defaults和正文逐字段比对。 groupBatchId不是productBatchId:本接口用运营团期主键;团期人员配置那组接口(/v3/admin/group-batch/{productBatchId}/staff)用的是产品侧排期 ID,两者不是同一个 ID 空间。- 通知书里没有出行人名单,且不会有——对客可分发文档不带出行人信息。
2. 保存出团通知书 PUT /v3/admin/order/group-batch/{groupBatchId}/docs/notice
VO: GroupBatchNoticeSaveReqVO
使用场景
通知书浮层「完成编辑」按钮。整体覆盖保存:9 个内容字段每次带齐。
原型里「完成编辑」目前只是
setEditing(false)、不发请求,需要 mmg 把本接口挂上去。
入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| groupBatchId | Path | Long | ✅ | 团期主订单 ID | 同上 |
| title | Body | String | ✅ | @NotBlank,≤128 |
通知书标题,不可为空串 |
| greeting | Body | String | ✅ | @NotNull,≤512 |
问候语,允许空串 |
| meetTime | Body | String | ✅ | @NotNull,≤64 |
集合时间。服务端不解析时间格式、不与出发日做强校验,随便填什么文案都行 |
| meetPlace | Body | String | ✅ | @NotNull,≤256 |
集合地点,允许空串 |
| leader | Body | String | ✅ | @NotNull,≤256 |
领队姓名与介绍。多领队在同一串内并列,接口不拆结构 |
| bus | Body | String | ✅ | @NotNull,≤256 |
车辆信息,允许空串 |
| contacts | Body | String | ✅ | @NotNull,≤256 |
联系方式。对客文档,手机号请按脱敏形态填写 |
| service | Body | String | ✅ | @NotNull,≤1024 |
服务包含说明,允许空串 |
| bring | Body | String | ✅ | @NotNull,≤1024 |
携带物品清单。只写物品名,禁写任何客户证件号码,命中即 589587 |
| expectedVersion | Body | Integer | ✅ | @NotNull,≥0 |
乐观锁版本。取自读接口的 version;首次保存传 0 |
原型把
contacts拆成leaderPhone/managerPhone两个输入框,本接口是单个contacts串,口径请 mmg 对齐。 原型「三 · 随身携带」「四 · 已含服务」目前渲染的是写死<li>列表、未接输入控件,需要补控件才能编辑service/bring。
出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| (同读接口全部字段) | — | 结构与读接口完全一致 |
| saved | Boolean | 保存成功后恒为 true |
| version | Integer | 写入后的新版本。下次保存把它当 expectedVersion 传回来 |
| updateTime | String | 本次写入时间,ISO-8601 秒精度 |
| defaults | Object | 实时默认值,供比对提示 |
请求示例
{
"title": "呼伦贝尔亲子研学 6 日 · 出团通知书",
"greeting": "亲爱的团友,欢迎参加本次行程!",
"meetTime": "2026-12-20 09:30",
"meetPlace": "海拉尔东山国际机场 T1 到达厅 3 号门",
"leader": "刘大山(10 年草原线经验)、萨仁高娃",
"bus": "mpv 蒙A-G8888",
"contacts": "领队 刘先生 138****1005 / 24 小时应急 400-888-8888",
"service": "含 5 早 8 正餐、5 晚住宿、全程用车、景区门票、旅游意外险",
"bring": "防晒霜、驱蚊液、外套(早晚温差 15℃)、常用药品",
"expectedVersion": 0
}
响应示例
{
"code": 200,
"message": "成功",
"data": {
"title": "呼伦贝尔亲子研学 6 日 · 出团通知书",
"greeting": "亲爱的团友,欢迎参加本次行程!",
"meetTime": "2026-12-20 09:30",
"meetPlace": "海拉尔东山国际机场 T1 到达厅 3 号门",
"leader": "刘大山(10 年草原线经验)、萨仁高娃",
"bus": "mpv 蒙A-G8888",
"contacts": "领队 刘先生 138****1005 / 24 小时应急 400-888-8888",
"service": "含 5 早 8 正餐、5 晚住宿、全程用车、景区门票、旅游意外险",
"bring": "防晒霜、驱蚊液、外套(早晚温差 15℃)、常用药品",
"saved": true,
"releasable": false,
"version": 1,
"updateTime": "2026-09-12T11:36:51",
"defaults": { "leader": "刘大山、萨仁高娃", "bus": "mpv 蒙A-G8888", "...": "其余同读接口" }
},
"success": true
}
空数据 / 降级响应
内容字段允许全部传空串(除 title 必须非空)。此时保存成功,读回来就是九个空串,saved=true。不存在「保存了但没生效」的降级分支——要么 200 落库,要么返业务错误码且零写入。
错误响应
| 码 | 符号 | 触发 | 前端该怎么办 |
|---|---|---|---|
| 589500 | GROUP_BATCH_NOT_FOUND |
团期不存在 / 已软删除 | 提示并关闭浮层 |
| 589507 | GROUP_BATCH_PERMISSION_DENIED |
无 group-batch:docs 权限 |
隐藏入口 |
| 589585 | NOTICE_CONCURRENT_MODIFICATION |
expectedVersion 与库内不符,或并发首次保存撞唯一键。零写入 |
重新 GET 读回全量再让用户提交;直接重试一定还是 589585 |
| 589587 | NOTICE_CONTENT_FORBIDDEN |
任一字段命中证件号形态(连续 ≥15 位数字 / 18 位身份证形态) | 提示用户删掉证件号;响应不回显命中的那串号码 |
| 400 | — | @Valid 长度 / 必填不过 |
按字段提示 |
{
"code": 589585,
"message": "通知书已被他人修改,请刷新后重试",
"data": null,
"traceId": null,
"success": false
}
{
"code": 589587,
"message": "通知书正文不可包含客户证件号码,请删除后重试",
"data": null,
"traceId": null,
"success": false
}
业务边界
- 保存不卡阶段:任意团期状态都能保存,包括招募中。阶段门只管下发(
releasable)。 releasable客户端传值忽略:它不在请求体里,服务端按团期状态算。- 同一秒内的并发保存也能判出冲突:锁用的是
version整数,不是时间戳。两名运营同时点保存,后一个必然拿到 589585 而不是静默覆盖前一个。 - 重复提交同一份内容不会产生第二份数据:一个团期恒一行(唯一键),且带
expectedVersion,重放第二次必然 CAS 失败。 - 证件号校验只看形态不看语义:连续 15 位以上数字(银行卡形态)同样会被拦。正常文案里的日期
2026-12-20、脱敏手机138****1005、座位数33 座不会误伤。
四、契约约束与正确调用方式
- 编辑流程固定是:GET 读全量 → 用户改 → PUT 带
expectedVersion全量提交 → 用响应里的新version更新本地状态。 跳过第一步直接 PUT 会因为拿不到正确的version而失败。 expectedVersion首次传0,之后一律用上一次响应的version,不要自己 +1。- 收到 589585 不要重试,要重读。 重试是原样再发一次,版本还是旧的。
- 正文按纯文本渲染,不要
v-html,也不要在前端做escape/unescape加工后再提交——那会把用户原文改掉。 releasable=false时置灰「打印 / 存 PDF」,但不要同时禁掉编辑与保存——招募期起草是允许的。defaults只用来做提示,不要自动覆盖用户正文。 想做「一键用最新默认值」按钮的话,把defaults填进表单让用户确认后再提交。- 两个 ID 别混用:本接口是
groupBatchId(运营团期主键);团期人员配置接口是productBatchId(产品侧排期 ID)。 updateTime是秒精度,两个端点形态一致,可直接new Date(...)解析。
五、数据库行为
新建表 group_batch_notice(Flyway V20260912_140__create_group_batch_notice.sql,团期 : 通知书 = 1 : 1):
| 列 | 类型 | 说明 |
|---|---|---|
notice_id |
BIGINT PK | 雪花 ID |
group_batch_id |
BIGINT | 唯一索引 uk_group_batch_id,并发首写由它兜住 |
title / greeting / meet_time / meet_place / leader / bus / contacts / service_included / bring |
VARCHAR | 九个正文列,NOT NULL DEFAULT ''。注意 service 在库里叫 service_included(避开 MySQL 易混词),API 字段名仍是 service |
version |
INT | 乐观锁版本,首次保存写入 1,此后每次 +1 |
create_time / update_time / created_by / updated_by / deleted_at |
— | 标准审计列 |
写行为:
- 读接口
GET完全不写库(草稿是内存里拼的)。 - 写接口
PUT只写group_batch_notice一张表:无行则INSERT(version=1),有行则按versionCASUPDATE。CAS 命中 0 行即抛 589585,零写入。 order_group_batch零改动:不加列、不改列、不推进batch_status、不写状态流水。- 不发任何消息 / 通知。
另一条 Flyway 在 hl-user-service:V20260912_140__add_group_batch_docs_permission.sql,注册权限码 group-batch:docs 并授予 ADMIN / SUPER_ADMIN(INSERT IGNORE,可安全重跑)。这两条迁移必须同批部署——否则端点对 ADMIN 不可用。
六、边界行为
| 场景 | 行为 |
|---|---|
| 从未保存过,GET | 200,saved=false、version=0、updateTime=null,正文 = 默认值 |
| 团期未配人员 | leader / contacts 空串,接口正常 200 |
| 团期无子订单 / 未配车 | bus 空串,接口正常 200 |
| 团期出发日为空 | meetTime 空串,不会出现 null 08:30 |
| 某户用车快照数据异常 | 只跳过那一户,bus 用其余户拼;读接口照常 200 |
| 团期状态是枚举外的历史脏值 | releasable=false,接口照常 200(不会因此 500) |
首次保存传 expectedVersion=0 |
200,version=1 |
表里无行但传了非 0 的 expectedVersion |
589585(客户端手上是已失效状态),零写入 |
| 两人同时首次保存 | 后到的撞唯一键 → 589585,零写入 |
| 两人同时改已存在的通知书 | 后提交的 CAS 命中 0 行 → 589585,零写入 |
正文含 & 或尖括号文字 |
原样存、原样返,多轮读写往返逐字不变 |
bring 里写了身份证号 |
589587,整份不保存 |
contacts 里写了银行卡号 |
589587,同上 |
| 招募中的团期保存 | 允许,200;只是 releasable=false |
七、不影响范围
order_group_batch表零改动,团期状态机、阶段推进、状态流水全部不受影响。- 团期人员配置那组接口(
/v3/admin/group-batch/{productBatchId}/staff*)零改动 —— 本单只读它,未改其行为。 - 用车需求 / 派车相关接口零改动 —— 本单只读用车快照。
- 既有权限码零改动:
group-batch:view/manage/export/list/finance:*/demand:confirm/contract:issue的号码、语义、授权关系全不变;本单只追加一个新码。 - 既有错误码零改动。
- 网关路由零改动(
/v3/admin/**通配已覆盖)。 - 小程序端(mp 域)零影响;其它微服务除 user-service 的一条权限种子外零改动。
- 不发任何通知消息。
八、测试环境已验证
部署:hl-order-service-v3 + hl-user-service 同 @ feat/7532-group-batch-notice(已合并 dev-v3 = 86ff9d115),均双实例滚动重启完成。全部实测经真实网关 api.test.1814.love:9443 + Bearer 鉴权。
Flyway 实测:flyway_schema_history → 20260912.140 / create group batch notice / success=1 / 57ms(order-v3)、20260912.140 / add group batch docs permission / success=1 / 8ms(user-service)。
| 场景 | 结果 |
|---|---|
| 未保存过 GET | 200,saved=false / version=0 / updateTime=null;leader="刘大山、萨仁高娃"、bus="mpv 蒙A-G8888"、contacts="刘大山 138****1005"(响应全文无明文手机号) |
保存(expectedVersion=0) |
200,saved=true / version=1;紧接着 GET 逐字段相同 |
再用 expectedVersion=0 提交 |
589585,随后 GET 内容与第一次保存完全一致、version 仍为 1(零写入) |
bring 传 18 位身份证形态 |
589587,随后 GET bring 未变、version 未递增(零写入) |
releasable 逐状态 |
RECRUITING/MATERIAL_PREPARING → false;PENDING_DEPARTURE/TRAVELLING/SETTLED → true |
| 读写往返恒等(两轮) | 正文 亲爱的团友,欢迎参加本次行程!A&B 与 <未加密>,GET→原样 PUT→GET 连做两轮,四次读到的正文逐字相等,从未出现 &;version 1→2→3 证明两次保存都真落了库。库内原文核对一致 |
| 权限门 | 种子生效后:ADMIN/SUPER_ADMIN → 200;FINANCE/CUSTOMIZER → 589507。阳性对照:同一 FINANCE token 调 group-batch:view 的端点 → 200 |
权限码缓存窗口实测:种子落库后 ADMIN 先返 589507,约 10 分钟后自动翻成 200(user-service PermissionService 的角色权限码 Redis 缓存 TTL 为 10 分钟)。上线后请勿把这段窗口当成漏配。
本地全量:mvn -o -pl hl-order-service-v3 -am test → Tests run: 9982, Failures: 0, Errors: 13, Skipped: 49(对账:基线 9958 + 本单 24 例)。13 个 Errors 全部是 Testcontainers 找不到 Docker,与本单零交集。门禁 RedLineArchTest 12/12、MapperBoundaryArchTest 26/26、LayerEnforcementTest 5/5、ErrorCodeUniquenessGuardTest 3/3 全绿。
测试环境造数已全部回收:通知书行已删、临时配的团期人员已清空、临时挂靠的订单归属已还原、团期状态已还原。
十、相关文档
- Issue:https://git.1814.love:8443/wx/HL/issues/7532
- PR:https://git.1814.love:8443/wx/HL/pulls/7577
- 契约卡片(已更新为「已落地」并记下三处偏离):
docs/group/团期模块接口文档-v2.0.htmlGB-ADM-081 / GB-ADM-082 - 新表:
hl-order-service-v3/src/main/resources/db/migration/V20260912_140__create_group_batch_notice.sql - 权限种子:
hl-user-service/src/main/resources/db/migration/V20260912_140__add_group_batch_docs_permission.sql
关联 / 联系人
- 后端:jw
- 前端(hl-ui 管理后台):mmg