--- schema: "hl-changelog/v2" ticket: "7532" title: "团期出团通知书读写端点(GB-ADM-081 / 082)" consumer: "admin" author: "jw(GIT)" change_type: "新增接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "95518b50" target_release: "" verified_at: "2026-09-13" status_note: "两个新端点。前端必接三件事:① 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 项全绿。" updated_at: "2026-09-13" base: "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` 时供前端比对提示「领队 / 用车已变更,是否更新」 | #### 请求示例 ```http GET /v3/admin/order/group-batch/2096495107078328322/docs/notice Authorization: Bearer ``` #### 响应示例 未保存过(草稿): ```json { "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` 权限,或请求未经网关鉴权 | ```json { "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 对齐。 > 原型「三 · 随身携带」「四 · 已含服务」目前渲染的是写死 `
  • ` 列表、未接输入控件,需要补控件才能编辑 `service` / `bring`。 #### 出参字段表 | 字段 | 类型 | 说明 | |------|------|------| | (同读接口全部字段) | — | 结构与读接口**完全一致** | | saved | Boolean | 保存成功后恒为 `true` | | version | Integer | **写入后的新版本**。下次保存把它当 `expectedVersion` 传回来 | | updateTime | String | 本次写入时间,ISO-8601 秒精度 | | defaults | Object | 实时默认值,供比对提示 | #### 请求示例 ```json { "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 } ``` #### 响应示例 ```json { "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` 长度 / 必填不过 | 按字段提示 | ```json { "code": 589585, "message": "通知书已被他人修改,请刷新后重试", "data": null, "traceId": null, "success": false } ``` ```json { "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`),有行则按 `version` CAS `UPDATE`。**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.html` GB-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