比较提交
1
次代码提交
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
6cbe22f40a |
@@ -41,3 +41,6 @@ jobs:
|
||||
|
||||
- name: Validate new changelog filenames
|
||||
run: npm run check:filenames -- --event "$GITHUB_EVENT_PATH"
|
||||
|
||||
- name: Validate changelog frontmatter
|
||||
run: npm run check:frontmatter -- --event "$GITHUB_EVENT_PATH"
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# 后端 API Changelog 推送与交接指南
|
||||
|
||||
> 本文可直接发送给后端同事。适用于 `wx/HL` 的管理后台与小程序接口变更。
|
||||
|
||||
## 一、什么时候必须推送 changelog
|
||||
|
||||
以下变化需要 changelog:
|
||||
|
||||
- Controller 路径、HTTP 方法或权限边界变化;
|
||||
- DTO、VO、BO、Feign 请求或响应字段变化;
|
||||
- 字段必填性、枚举、状态、金额、空值或兼容行为变化;
|
||||
- 新增、修改、废弃或删除管理后台/小程序接口;
|
||||
- 前端或其他调用方需要调整请求、解析或页面行为。
|
||||
|
||||
纯后端内部重构且外部契约完全不变时,可不创建;必须在工单中说明 `frontend_status: not_required` 的判断依据。
|
||||
|
||||
## 二、准备条件
|
||||
|
||||
1. 已有关联的合格 Gitea 工单。
|
||||
2. 已确认目标端:
|
||||
- 管理后台:`changelogs-v2/`
|
||||
- 小程序端:`changelogs-v2-mp/`
|
||||
3. 已确认变更类型:`新增接口`、`修改接口` 或 `删除接口`。
|
||||
4. `D:/work2/hl-ui` 保持只读,不在前端仓库创建配合工单。
|
||||
5. changelog 仓库使用独立任务分支或 worktree,不把其他线程的未跟踪文件一起提交。
|
||||
|
||||
## 三、生成草稿
|
||||
|
||||
预览:
|
||||
|
||||
```powershell
|
||||
hl changelog draft 5205 "车务首页汇总状态补全" `
|
||||
--repo D:/work2/HL-v3-worktrees/5205 `
|
||||
--base dev-v3 `
|
||||
--track v3 `
|
||||
--consumer admin `
|
||||
--change-type 修改接口
|
||||
```
|
||||
|
||||
确认目标路径和检测到的 Controller/DTO/VO/Feign 文件后写入:
|
||||
|
||||
```powershell
|
||||
hl changelog draft 5205 "车务首页汇总状态补全" `
|
||||
--repo D:/work2/HL-v3-worktrees/5205 `
|
||||
--base dev-v3 `
|
||||
--track v3 `
|
||||
--consumer admin `
|
||||
--change-type 修改接口 `
|
||||
--write
|
||||
```
|
||||
|
||||
`--write` 会自动获取 `changelog` 单写租约。手工创建或修改 changelog 时,应先执行:
|
||||
|
||||
```powershell
|
||||
hl resource acquire changelog --ticket 5205 --ttl 1800
|
||||
```
|
||||
|
||||
## 四、文件名
|
||||
|
||||
管理后台:
|
||||
|
||||
```text
|
||||
changelogs-v2/YYYY-MM/DD_issue_业务标题-{新增接口|修改接口|删除接口}-管理后台.md
|
||||
```
|
||||
|
||||
小程序:
|
||||
|
||||
```text
|
||||
changelogs-v2-mp/YYYY-MM/DD_issue_业务标题-{新增接口|修改接口|删除接口}-小程序端.md
|
||||
```
|
||||
|
||||
年月日必须使用提交时 `Asia/Shanghai` 的真实日期。状态不得写入文件名,不要增加“前端待处理”“已完成”等额外片段。
|
||||
|
||||
## 五、填写 v2 元数据
|
||||
|
||||
```yaml
|
||||
---
|
||||
schema: "hl-changelog/v2"
|
||||
ticket: "5205"
|
||||
title: "车务首页汇总状态补全"
|
||||
consumer: "admin"
|
||||
change_type: "修改接口"
|
||||
backend_status: "deployed"
|
||||
gateway_status: "verified"
|
||||
frontend_status: "pending"
|
||||
frontend_owner: ""
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
status_note: ""
|
||||
updated_at: "2026-07-24"
|
||||
base: "dev-v3"
|
||||
---
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 自动草稿从 `backend_status: pending`、`gateway_status: pending` 开始。
|
||||
- 后端实际部署完成后才能改为 `backend_status: deployed`。
|
||||
- 经网关验证后填写 `gateway_status: verified`;确实无需网关验证时使用 `not_required`。
|
||||
- 需要前端配合时初始化 `frontend_status: pending`。
|
||||
- 不需要前端修改时使用 `frontend_status: not_required`。
|
||||
- 后端不得代替前端填写 `implemented`、`released` 或 `verified`。
|
||||
|
||||
## 六、正文必须写清
|
||||
|
||||
- 关联 Issue 和 PR;
|
||||
- 变更接口清单;
|
||||
- 请求与响应字段;
|
||||
- 枚举、状态、空值、ID 和金额规则;
|
||||
- 老数据和兼容行为;
|
||||
- 前端/调用方需要采取的动作;
|
||||
- 定向测试、网关验证和兼容性证据;
|
||||
- 不影响范围。
|
||||
|
||||
页面展示、列表、汇总、看板、状态标签或颜色变化,还必须在后端工单中准备展示矩阵,明确数据来源、状态范围、空态、颜色和守恒规则。
|
||||
|
||||
## 七、本地校验
|
||||
|
||||
在 changelog 仓库执行:
|
||||
|
||||
```powershell
|
||||
npm test
|
||||
npm run check:filenames -- --base origin/main --head HEAD
|
||||
npm run check:frontmatter -- --base origin/main --head HEAD
|
||||
```
|
||||
|
||||
单文件还可以执行:
|
||||
|
||||
```powershell
|
||||
hl changelog lint D:/path/changelog.md
|
||||
```
|
||||
|
||||
发布前 lint 允许前端仍是 `pending`,但要求:
|
||||
|
||||
- `backend_status: deployed`;
|
||||
- `gateway_status` 不再是 `pending`;
|
||||
- 正文不存在 `TODO`、`待补充` 或模板占位符。
|
||||
|
||||
## 八、提交和推送
|
||||
|
||||
只暂存本任务文件,禁止使用会卷入其他线程文件的宽泛命令:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git add -- changelogs-v2/2026-07/24_5205_车务首页汇总状态补全-修改接口-管理后台.md
|
||||
git diff --cached --check
|
||||
git commit -m "docs: hand off fleet dashboard contract (#5205)"
|
||||
git push -u origin <任务分支>
|
||||
```
|
||||
|
||||
随后向 `main` 创建 PR。合并前再次检查上海日期;跨越上海零点且仍未合并时,按贡献规则重命名为当天日期。
|
||||
|
||||
不要直接提交:
|
||||
|
||||
- 其他线程的 changelog;
|
||||
- `.tmp-*` 文件;
|
||||
- token、密码、证书、真实隐私数据;
|
||||
- `hl-ui` 代码。
|
||||
|
||||
## 九、回写后端任务
|
||||
|
||||
合并后在后端工单和任务台账记录:
|
||||
|
||||
- changelog 文件路径;
|
||||
- changelog 提交或 PR;
|
||||
- 当前 `frontend_status`;
|
||||
- 后端部署和网关验证证据。
|
||||
|
||||
```powershell
|
||||
hl task update 5205 `
|
||||
--changelog D:/path/changelog.md
|
||||
```
|
||||
|
||||
后端工单可以按后端验收范围关闭;前端继续在同一 changelog 中推进消费状态。
|
||||
|
||||
手工持有租约时,完成后释放:
|
||||
|
||||
```powershell
|
||||
hl resource release changelog --ticket 5205
|
||||
```
|
||||
@@ -1,3 +1,21 @@
|
||||
---
|
||||
schema: "hl-changelog/v2"
|
||||
ticket: "{issue-no}"
|
||||
title: "{一句话概括变化}"
|
||||
consumer: "{admin|mp|internal|multiple}"
|
||||
change_type: "{新增接口|修改接口|删除接口}"
|
||||
backend_status: "pending"
|
||||
gateway_status: "pending"
|
||||
frontend_status: "{pending|not_required}"
|
||||
frontend_owner: ""
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
status_note: ""
|
||||
updated_at: "YYYY-MM-DD"
|
||||
base: "{dev|dev-v3}"
|
||||
---
|
||||
|
||||
# {模块名}: {一句话概括变化}
|
||||
|
||||
> **存放目录**:
|
||||
|
||||
+36
@@ -42,6 +42,42 @@ npm run check:filenames -- --base origin/main --head HEAD
|
||||
|
||||
生产 CLI 故意不提供 `--date` 或日期环境变量;测试只通过导出的纯函数注入 `Date`。规则失败返回退出码 `1`,Git/事件/参数等基础设施错误返回 `2`。
|
||||
|
||||
## 前端消费状态
|
||||
|
||||
新增二期 changelog 必须使用 `hl-changelog/v2` YAML Front Matter。状态只写在元数据中,不写入文件名:
|
||||
|
||||
```yaml
|
||||
backend_status: "deployed"
|
||||
gateway_status: "verified"
|
||||
frontend_status: "pending"
|
||||
frontend_owner: ""
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
updated_at: "2026-07-24"
|
||||
```
|
||||
|
||||
前端状态正常流转为:
|
||||
|
||||
```text
|
||||
pending → claimed → implemented → released → verified
|
||||
```
|
||||
|
||||
不需要前端修改时使用 `not_required`。字段一致性、必填证据和新增文档 frontmatter 由 `check:frontmatter` 校验。
|
||||
|
||||
完整职责和命令见:
|
||||
|
||||
- `FRONTEND_CONSUMPTION_STATUS_GUIDE.md`
|
||||
- `BACKEND_CHANGELOG_DELIVERY_GUIDE.md`
|
||||
|
||||
本地校验:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run check:filenames -- --base origin/main --head HEAD
|
||||
npm run check:frontmatter -- --base origin/main --head HEAD
|
||||
```
|
||||
|
||||
## CI 与服务端阻断边界
|
||||
|
||||
Gitea Actions 会在指向 `main` 的 PR 和 `main` 的 push 上运行回归测试与文件名检测。该 workflow 是检测器:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# API Changelog 前端消费状态协作说明
|
||||
|
||||
## 可直接转发给前端的通知
|
||||
|
||||
API changelog 从 `hl-changelog/v2` 开始记录前端消费进度。后端交接时会填写:
|
||||
|
||||
```yaml
|
||||
backend_status: "deployed"
|
||||
frontend_status: "pending"
|
||||
frontend_owner: ""
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
```
|
||||
|
||||
请前端在领取、实现、发布和页面验证时更新对应状态,并填写可追溯的前端 PR、提交或发布版本。
|
||||
|
||||
这不会把前端工作纳入后端工单验收,也不要求在 `hl-ui` 创建配合工单。它只用于区分:
|
||||
|
||||
- 后端接口是否已经部署并验证;
|
||||
- 前端是否已经领取;
|
||||
- 前端代码是否已经实现;
|
||||
- 页面是否已经发布并验证。
|
||||
|
||||
只有 `frontend_status: "verified"` 才表示用户页面形成完整闭环。
|
||||
|
||||
## 状态流转
|
||||
|
||||
```text
|
||||
pending → claimed → implemented → released → verified
|
||||
```
|
||||
|
||||
不需要前端修改时:
|
||||
|
||||
```text
|
||||
not_required
|
||||
```
|
||||
|
||||
| 状态 | 含义 | 必填证据 |
|
||||
|---|---|---|
|
||||
| `not_required` | 不需要前端修改 | 不填写前端负责人、引用和版本 |
|
||||
| `pending` | 等待前端领取 | 无 |
|
||||
| `claimed` | 前端已领取 | `frontend_owner` |
|
||||
| `implemented` | 前端代码已实现 | `frontend_owner`、`frontend_ref` |
|
||||
| `released` | 已发布 | 再填写 `target_release` |
|
||||
| `verified` | 页面已验证 | 再填写 `verified_at` |
|
||||
|
||||
跨级迁移会被自动校验拒绝。状态回退或改为/取消 `not_required` 时必须填写原因。
|
||||
|
||||
## 更新命令
|
||||
|
||||
领取:
|
||||
|
||||
```powershell
|
||||
hl changelog transition 5205 D:/path/changelog.md claimed `
|
||||
--owner frontend-team --write
|
||||
```
|
||||
|
||||
实现:
|
||||
|
||||
```powershell
|
||||
hl changelog transition 5205 D:/path/changelog.md implemented `
|
||||
--owner frontend-team `
|
||||
--frontend-ref "mmg/hl-ui@abc1234" `
|
||||
--write
|
||||
```
|
||||
|
||||
发布:
|
||||
|
||||
```powershell
|
||||
hl changelog transition 5205 D:/path/changelog.md released `
|
||||
--target-release "test-2026.07.24" `
|
||||
--write
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```powershell
|
||||
hl changelog transition 5205 D:/path/changelog.md verified `
|
||||
--verified-at "2026-07-24" `
|
||||
--write
|
||||
```
|
||||
|
||||
命令默认只预览;只有 `--write` 才修改文件。写入命令会自动获取 `changelog` 单写租约。
|
||||
|
||||
## 职责边界
|
||||
|
||||
后端负责:
|
||||
|
||||
- 完成后端测试、部署和网关验证;
|
||||
- 初始化 v2 元数据;
|
||||
- 需要前端时设置 `pending`,不需要时设置 `not_required`;
|
||||
- 不替前端填写 `implemented`、`released` 或 `verified`。
|
||||
|
||||
前端负责:
|
||||
|
||||
- 领取时填写负责人;
|
||||
- 实现后填写前端引用;
|
||||
- 发布后填写目标版本或环境;
|
||||
- 页面验证后填写验证日期。
|
||||
|
||||
QA 或产品可以协助更新 `verified_at`,但必须基于实际页面验证,不能只根据接口成功或代码已合并标记完成。
|
||||
|
||||
## 存量文档
|
||||
|
||||
- 新 changelog 全部使用 `hl-changelog/v2`。
|
||||
- `hl-changelog/v1` 继续可读和索引,不强制一次性迁移。
|
||||
- 文件名带“前端待处理”不代表真实状态;需要继续流转时补充 v2 元数据。
|
||||
- 不通过重命名表达消费状态,避免破坏文件名校验和历史链接。
|
||||
@@ -1,191 +0,0 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5216"
|
||||
title: "派车看板补充槽位接送路线与就绪摘要"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "pending"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-24T14:24:00+08:00"
|
||||
---
|
||||
|
||||
# 【修改接口·前端待处理·管理后台】派车看板补充槽位接送路线与就绪摘要
|
||||
|
||||
## 目标前端
|
||||
|
||||
- 端类型:管理后台(Web)
|
||||
- 目标仓库:`mmg/hl-ui`
|
||||
- 目标分支:`v2.1`
|
||||
- 联调/验收环境:<http://192.168.100.160:9527>
|
||||
- 小程序:无需处理
|
||||
|
||||
> **服务**: hl-order-service-v3、hl-fleet-service
|
||||
>
|
||||
> **工单**: [wx/HL#5216](https://git.1814.love:8443/wx/HL/issues/5216)
|
||||
>
|
||||
> **影响范围**: 车务管理 → 派车看板卡片
|
||||
|
||||
## 业务口径
|
||||
|
||||
派车看板卡片本身应足够车务完成日常派车判断,详情页只用于查看更深信息。每张卡对应一个稳定车辆槽位,
|
||||
同时显示当前需求全部槽位的派车进度、该槽位服务范围、接送批次、路线和资料就绪风险。
|
||||
|
||||
- 看板仍按车辆槽位维度返回,不改为订单维度。
|
||||
- 只统计订单当前有效用车需求,不混入已驳回、已失活或旧版本需求。
|
||||
- 行程或大交通缺失只作风险提示,`readiness.blocksAssignment` 固定为 `false`,不改变 `canAssign`。
|
||||
- 卡片接送摘要不返回出行人、接送备注或大交通自由文本备注。
|
||||
|
||||
## 变更接口
|
||||
|
||||
```http
|
||||
GET /admin/fleet/board/orders
|
||||
```
|
||||
|
||||
请求参数、筛选、排序、分页和 `records[]` 维度不变;每条 `records[]` 新增以下字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"assignmentSlotId": "2080200000000000001",
|
||||
"slotSummary": {
|
||||
"assignmentSlotId": "2080200000000000001",
|
||||
"slotIndex": 2,
|
||||
"totalSlots": 3,
|
||||
"serviceStartDate": "2026-07-29",
|
||||
"serviceEndDate": "2026-07-31",
|
||||
"serviceDays": 3,
|
||||
"requiredVehicleType": "mpv",
|
||||
"requiredVehicleTypeLabel": "商务车",
|
||||
"requiredSeats": 7
|
||||
},
|
||||
"assignmentProgress": {
|
||||
"totalSlots": 3,
|
||||
"unassignedSlots": 1,
|
||||
"holdingSlots": 1,
|
||||
"assignedSlots": 1,
|
||||
"completedSlots": 0,
|
||||
"canceledSlots": 0
|
||||
},
|
||||
"pickupSummary": {
|
||||
"required": true,
|
||||
"statusCode": "PARTIAL",
|
||||
"statusLabel": "接客信息部分缺失",
|
||||
"batchCount": 2,
|
||||
"readyBatchCount": 1,
|
||||
"transportNos": ["MU8345", "K7091"],
|
||||
"earliestTime": "2026-07-29T10:30:00",
|
||||
"latestTime": "2026-07-29T15:20:00",
|
||||
"stations": ["海拉尔机场"]
|
||||
},
|
||||
"dropoffSummary": {
|
||||
"required": true,
|
||||
"statusCode": "READY",
|
||||
"statusLabel": "送客信息已齐",
|
||||
"batchCount": 1,
|
||||
"readyBatchCount": 1,
|
||||
"transportNos": ["CA1234"],
|
||||
"earliestTime": "2026-07-31T18:00:00",
|
||||
"latestTime": "2026-07-31T18:00:00",
|
||||
"stations": ["海拉尔站"]
|
||||
},
|
||||
"routeSummary": "海拉尔区 → 额尔古纳市 → 满洲里市",
|
||||
"daysUntilDeparture": 5,
|
||||
"readiness": {
|
||||
"statusCode": "PARTIAL",
|
||||
"statusLabel": "部分信息待补",
|
||||
"itineraryStatusCode": "READY",
|
||||
"itineraryStatusLabel": "行程已完整",
|
||||
"itineraryDayCount": 3,
|
||||
"itineraryExpectedDayCount": 3,
|
||||
"missingItemCodes": ["PICKUP_TRANSFER"],
|
||||
"missingItemLabels": ["接客信息"],
|
||||
"blocksAssignment": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 当前槽位 `slotSummary`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `assignmentSlotId` | `String` | 稳定车辆槽位 ID,按雪花 ID 字符串处理 |
|
||||
| `slotIndex` | `Integer/null` | 当前槽位序号,**从 1 开始** |
|
||||
| `totalSlots` | `Integer` | 当前有效需求车辆槽位总数 |
|
||||
| `serviceStartDate/serviceEndDate` | `LocalDate/null` | 当前槽位实际服务范围 |
|
||||
| `serviceDays` | `Integer/null` | 服务范围闭区间天数 |
|
||||
| `requiredVehicleType` | `String/null` | 当前槽位车型规范编码 |
|
||||
| `requiredVehicleTypeLabel` | `String` | 当前槽位车型中文标签 |
|
||||
| `requiredSeats` | `Integer/null` | 当前槽位要求座位数 |
|
||||
|
||||
不要用既有整单 `requiredVehicles[]` 的数组位置猜当前卡片车型;当前卡片只读取 `slotSummary`。
|
||||
|
||||
### 整单进度 `assignmentProgress`
|
||||
|
||||
`assignmentProgress` 基于当前有效需求的全部稳定槽位计算,不受本次列表状态、车型、日期或关键词筛选影响。
|
||||
前端可直接展示“3 车:待派 1 / 排车中 1 / 已派 1”,不要用当前页 `records[]` 自行计数。
|
||||
|
||||
### 接送摘要 `pickupSummary/dropoffSummary`
|
||||
|
||||
| `statusCode` | 含义 |
|
||||
| --- | --- |
|
||||
| `READY` | 所有批次时间和站点均完整 |
|
||||
| `PARTIAL` | 至少一个批次完整,但仍有批次缺时间或站点 |
|
||||
| `MISSING` | 当前要求该方向接送,但没有完整批次 |
|
||||
| `NOT_REQUIRED` | 当前用车需求明确不要求该方向接送 |
|
||||
| `SOURCE_UNAVAILABLE` | order-v3 暂不可用,不能把它显示成“无需接送”或“资料已齐” |
|
||||
|
||||
`pickupAt/dropoffAt` 兼容字段继续保留;订单实时上下文可用时,优先回填对应方向第一个有效站点。
|
||||
|
||||
### 行程与就绪度
|
||||
|
||||
- `routeSummary` 按行程天顺序生成,并压缩连续重复地点;无地点时为 `null`。
|
||||
- `daysUntilDeparture` 是服务端当前日期到出团日的自然日数;负数表示已出团。
|
||||
- `readiness.statusCode` 为 `READY/PARTIAL/MISSING/SOURCE_UNAVAILABLE`。
|
||||
- `missingItemCodes` 当前可能包含:
|
||||
- `ORDER_CONTEXT`:订单实时信息不可用;
|
||||
- `ITINERARY_DAYS`:逐日行程缺失、天数不完整或日期仍是旧档期;
|
||||
- `ROUTE_SUMMARY`:行程天没有可用地点;
|
||||
- `PICKUP_TRANSFER`:要求接客但资料不完整;
|
||||
- `DROPOFF_TRANSFER`:要求送客但资料不完整。
|
||||
|
||||
页面使用后端 `statusLabel/missingItemLabels` 展示中文,不自行翻译状态码。
|
||||
|
||||
## 前端处理清单
|
||||
|
||||
- [ ] 卡片主信息区展示“第 `slotIndex/totalSlots` 车”、车型标签、座位数和槽位服务日期。
|
||||
- [ ] 展示 `assignmentProgress` 整单进度,不按当前页或筛选后记录重新计算。
|
||||
- [ ] 分别展示接客和送客摘要;多批次显示批次数、班次/车次、时间范围和站点。
|
||||
- [ ] `NOT_REQUIRED` 显示“无需接客/无需送客”,`SOURCE_UNAVAILABLE` 显示“信息暂不可用”。
|
||||
- [ ] 展示 `routeSummary`、`daysUntilDeparture` 和 `readiness` 风险提示。
|
||||
- [ ] 资料缺失时不得禁用派车按钮;操作能力继续只读 `canAssign/availableActionCodes`。
|
||||
- [ ] 不在卡片展示出行人、接送备注、大交通备注等敏感或自由文本信息。
|
||||
- [ ] `assignmentSlotId/assignmentId/assignmentGroupId/requirementId/orderId` 均按字符串处理。
|
||||
- [ ] 覆盖单车、多车、部分已派、多批次接送、无需接送、资料缺失和下游降级场景。
|
||||
|
||||
## 不影响范围
|
||||
|
||||
- 不修改派车看板请求参数、分页、筛选、排序和操作接口。
|
||||
- 不修改 `canAssign/canRejectRequirement/availableActionCodes` 计算。
|
||||
- 不修改派单详情、矩阵派单、司机车辆占用、保险和费用。
|
||||
- 不迁移数据库,不写入订单、行程、大交通或派单数据。
|
||||
|
||||
## 验证证据
|
||||
|
||||
- 后端提交:`e73740c57caf295cac96d964e540279f581f1fb0`;PR:
|
||||
[wx/HL#5221](https://git.1814.love:8443/wx/HL/pulls/5221)。
|
||||
- `mvn -pl hl-fleet-service -am verify` 通过:2354 tests,0 failures/errors,skipped 1;
|
||||
Spotless 603 files clean。
|
||||
- `OrderFleetProviderServiceTest` 33 项、`BoardOrderServiceTest` 48 项及
|
||||
`AssignmentServiceTest` 稳定槽位聚合测试均通过。
|
||||
- `mvn -pl hl-order-service-v3 -am verify` 共执行 6643 tests,其中 6642 项通过;
|
||||
唯一错误是上游 `SettlementFinancialChecksMigrationTest` 在当前环境无法发现 Docker,
|
||||
与本次接口变更无关。Issue #5216 的对应验收项因此仍保持未勾选。
|
||||
- 功能分支部署任务:order-v3 `d0e26c90`、fleet `a4776dfa`,均成功完成双实例滚动部署。
|
||||
- 经测试网关实测 `GET /admin/fleet/board/orders?page=1&pageSize=20`:HTTP 200,返回 3 条真实记录;
|
||||
8 个新增字段在 3 条记录中全部存在且非空,观测到就绪状态 `PARTIAL`,接送状态
|
||||
`READY/MISSING`。
|
||||
- Nacos 实测:`hl-order-service-v3` 8086/8186、`hl-fleet-service` 8087/8187 均为 2/2 健康;
|
||||
四个新实例均有正常启动记录,启动后的运行日志未发现新增 ERROR、FATAL、Exception 或
|
||||
`Caused by`。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`;前端按“前端处理清单”接入即可。
|
||||
+3
-2
@@ -3,8 +3,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/validate-changelog-filenames.test.mjs",
|
||||
"check:filenames": "node scripts/validate-changelog-filenames.mjs"
|
||||
"test": "node --test tests/validate-changelog-filenames.test.mjs tests/validate-changelog-frontmatter.test.mjs",
|
||||
"check:filenames": "node scripts/validate-changelog-filenames.mjs",
|
||||
"check:frontmatter": "node scripts/validate-changelog-frontmatter.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -76,7 +76,7 @@ export function collectNewTargetPaths(records) {
|
||||
.map(({ targetPath }) => targetPath);
|
||||
}
|
||||
|
||||
function controlledRootForPath(inputPath) {
|
||||
export function controlledRootForPath(inputPath) {
|
||||
const candidate = String(inputPath);
|
||||
for (const root of CONTROLLED_ROOTS.keys()) {
|
||||
if (candidate === root || candidate.startsWith(`${root}/`) || candidate.startsWith(`${root}\\`)) {
|
||||
@@ -190,7 +190,7 @@ export function runValidation(records, now = new Date()) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
export function parseArguments(argv) {
|
||||
const options = {};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const flag = argv[index];
|
||||
@@ -257,7 +257,7 @@ function gitDiff(revisions) {
|
||||
});
|
||||
}
|
||||
|
||||
function diffFromOptions(options) {
|
||||
export function diffFromOptions(options) {
|
||||
if (!options.event) {
|
||||
const base = resolveCommitRef(options.base, 'base ref');
|
||||
const head = resolveCommitRef(options.head, 'head ref');
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import {
|
||||
controlledRootForPath,
|
||||
diffFromOptions,
|
||||
parseArguments,
|
||||
parseNameStatusZ,
|
||||
} from './validate-changelog-filenames.mjs';
|
||||
|
||||
const FRONTEND_STATUSES = new Set([
|
||||
'not_required',
|
||||
'pending',
|
||||
'claimed',
|
||||
'implemented',
|
||||
'released',
|
||||
'verified',
|
||||
]);
|
||||
const BACKEND_STATUSES = new Set(['pending', 'tested', 'deployed']);
|
||||
const GATEWAY_STATUSES = new Set(['pending', 'verified', 'not_required']);
|
||||
const CONSUMERS = new Set(['admin', 'mp', 'internal', 'multiple']);
|
||||
const CHANGE_TYPES = new Set(['新增接口', '修改接口', '删除接口']);
|
||||
const REQUIRED_KEYS = [
|
||||
'schema',
|
||||
'ticket',
|
||||
'title',
|
||||
'consumer',
|
||||
'change_type',
|
||||
'backend_status',
|
||||
'gateway_status',
|
||||
'frontend_status',
|
||||
'frontend_owner',
|
||||
'frontend_ref',
|
||||
'target_release',
|
||||
'verified_at',
|
||||
'updated_at',
|
||||
'base',
|
||||
];
|
||||
|
||||
function ruleError(code, file, message) {
|
||||
return { code, path: file, message };
|
||||
}
|
||||
|
||||
function isIsoDate(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const parsed = new Date(Date.UTC(year, month - 1, day));
|
||||
return parsed.getUTCFullYear() === year
|
||||
&& parsed.getUTCMonth() === month - 1
|
||||
&& parsed.getUTCDate() === day;
|
||||
}
|
||||
|
||||
function isIsoDateOrTime(value) {
|
||||
if (isIsoDate(value)) {
|
||||
return true;
|
||||
}
|
||||
return /^\d{4}-\d{2}-\d{2}T/.test(value ?? '')
|
||||
&& Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
export function parseFrontmatter(text) {
|
||||
const value = String(text ?? '').replaceAll('\r\n', '\n');
|
||||
if (!value.startsWith('---\n')) {
|
||||
return { metadata: undefined, body: value };
|
||||
}
|
||||
const end = value.indexOf('\n---\n', 4);
|
||||
if (end < 0) {
|
||||
return { metadata: undefined, body: value };
|
||||
}
|
||||
const metadata = {};
|
||||
for (const line of value.slice(4, end).split('\n')) {
|
||||
const separator = line.indexOf(':');
|
||||
if (separator < 0) {
|
||||
continue;
|
||||
}
|
||||
const key = line.slice(0, separator).trim();
|
||||
let fieldValue = line.slice(separator + 1).trim();
|
||||
if (
|
||||
(fieldValue.startsWith('"') && fieldValue.endsWith('"'))
|
||||
|| (fieldValue.startsWith("'") && fieldValue.endsWith("'"))
|
||||
) {
|
||||
fieldValue = fieldValue.slice(1, -1);
|
||||
}
|
||||
metadata[key] = fieldValue;
|
||||
}
|
||||
return { metadata, body: value.slice(end + 5) };
|
||||
}
|
||||
|
||||
export function validateFrontendState(metadata) {
|
||||
const errors = [];
|
||||
const status = metadata.frontend_status;
|
||||
const owner = metadata.frontend_owner?.trim() ?? '';
|
||||
const reference = metadata.frontend_ref?.trim() ?? '';
|
||||
const release = metadata.target_release?.trim() ?? '';
|
||||
const verifiedAt = metadata.verified_at?.trim() ?? '';
|
||||
if (!FRONTEND_STATUSES.has(status)) {
|
||||
return [`frontend_status 非法: ${status || '(空)'}`];
|
||||
}
|
||||
if (['claimed', 'implemented', 'released', 'verified'].includes(status) && !owner) {
|
||||
errors.push(`${status} 必须填写 frontend_owner`);
|
||||
}
|
||||
if (['implemented', 'released', 'verified'].includes(status) && !reference) {
|
||||
errors.push(`${status} 必须填写 frontend_ref`);
|
||||
}
|
||||
if (['released', 'verified'].includes(status) && !release) {
|
||||
errors.push(`${status} 必须填写 target_release`);
|
||||
}
|
||||
if (status === 'verified' && !verifiedAt) {
|
||||
errors.push('verified 必须填写 verified_at');
|
||||
}
|
||||
if (verifiedAt && !isIsoDateOrTime(verifiedAt)) {
|
||||
errors.push('verified_at 必须是 ISO 日期或时间');
|
||||
}
|
||||
if (status === 'not_required' && [owner, reference, release, verifiedAt].some(Boolean)) {
|
||||
errors.push('not_required 不得保留前端负责人、引用、版本或验证时间');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateFrontendTransition(current, target, reason = '') {
|
||||
if (!FRONTEND_STATUSES.has(current) || !FRONTEND_STATUSES.has(target)) {
|
||||
return ['frontend_status 非法'];
|
||||
}
|
||||
if (current === target) {
|
||||
return [];
|
||||
}
|
||||
if (current === 'not_required' || target === 'not_required') {
|
||||
return reason.trim() ? [] : ['涉及 not_required 的迁移必须填写原因'];
|
||||
}
|
||||
const order = ['pending', 'claimed', 'implemented', 'released', 'verified'];
|
||||
const currentIndex = order.indexOf(current);
|
||||
const targetIndex = order.indexOf(target);
|
||||
if (targetIndex === currentIndex + 1) {
|
||||
return [];
|
||||
}
|
||||
if (targetIndex < currentIndex) {
|
||||
return reason.trim() ? [] : ['状态回退必须填写原因'];
|
||||
}
|
||||
return [`禁止跨级迁移: ${current} -> ${target}`];
|
||||
}
|
||||
|
||||
export function validateV2Document(file, text, { requireV2 = false } = {}) {
|
||||
const { metadata, body } = parseFrontmatter(text);
|
||||
if (!metadata) {
|
||||
return requireV2 ? [ruleError('E_FRONTMATTER', file, '新增 changelog 缺少 YAML Front Matter')] : [];
|
||||
}
|
||||
if (metadata.schema !== 'hl-changelog/v2') {
|
||||
return requireV2
|
||||
? [ruleError('E_SCHEMA', file, `新增 changelog 必须使用 hl-changelog/v2,当前为 ${metadata.schema || '(空)'}`)]
|
||||
: [];
|
||||
}
|
||||
const errors = [];
|
||||
for (const key of REQUIRED_KEYS) {
|
||||
if (!(key in metadata)) {
|
||||
errors.push(ruleError('E_REQUIRED', file, `frontmatter 缺少 ${key}`));
|
||||
}
|
||||
}
|
||||
for (const key of ['ticket', 'title', 'consumer', 'change_type', 'backend_status', 'gateway_status', 'frontend_status', 'updated_at', 'base']) {
|
||||
if (!metadata[key]?.trim()) {
|
||||
errors.push(ruleError('E_REQUIRED', file, `${key} 不能为空`));
|
||||
}
|
||||
}
|
||||
if (!CHANGE_TYPES.has(metadata.change_type)) {
|
||||
errors.push(ruleError('E_CHANGE_TYPE', file, `change_type 非法: ${metadata.change_type || '(空)'}`));
|
||||
}
|
||||
if (!CONSUMERS.has(metadata.consumer)) {
|
||||
errors.push(ruleError('E_CONSUMER', file, `consumer 非法: ${metadata.consumer || '(空)'}`));
|
||||
}
|
||||
if (!BACKEND_STATUSES.has(metadata.backend_status)) {
|
||||
errors.push(ruleError('E_BACKEND_STATUS', file, `backend_status 非法: ${metadata.backend_status || '(空)'}`));
|
||||
}
|
||||
if (!GATEWAY_STATUSES.has(metadata.gateway_status)) {
|
||||
errors.push(ruleError('E_GATEWAY_STATUS', file, `gateway_status 非法: ${metadata.gateway_status || '(空)'}`));
|
||||
}
|
||||
if (metadata.backend_status !== 'deployed') {
|
||||
errors.push(ruleError('E_BACKEND_PENDING', file, '发布的 changelog 必须是 backend_status=deployed'));
|
||||
}
|
||||
if (metadata.gateway_status === 'pending') {
|
||||
errors.push(ruleError('E_GATEWAY_PENDING', file, '发布的 changelog 不能保留 gateway_status=pending'));
|
||||
}
|
||||
for (const message of validateFrontendState(metadata)) {
|
||||
errors.push(ruleError('E_FRONTEND_STATE', file, message));
|
||||
}
|
||||
if (metadata.consumer === 'internal' && metadata.frontend_status !== 'not_required') {
|
||||
errors.push(ruleError('E_FRONTEND_STATE', file, 'internal consumer 必须使用 frontend_status=not_required'));
|
||||
}
|
||||
if (!isIsoDate(metadata.updated_at)) {
|
||||
errors.push(ruleError('E_UPDATED_AT', file, 'updated_at 必须是真实的 YYYY-MM-DD 日期'));
|
||||
}
|
||||
const filename = path.posix.basename(file);
|
||||
const issue = /^\d{2}_([1-9]\d*)_/.exec(filename)?.[1];
|
||||
if (issue && metadata.ticket !== issue) {
|
||||
errors.push(ruleError('E_TICKET_MISMATCH', file, `ticket=${metadata.ticket} 与文件名 Issue=${issue} 不一致`));
|
||||
}
|
||||
const filenameType = /-(新增接口|修改接口|删除接口)-(?:管理后台|小程序端)\.md$/.exec(filename)?.[1];
|
||||
if (filenameType && metadata.change_type !== filenameType) {
|
||||
errors.push(ruleError('E_TYPE_MISMATCH', file, `change_type=${metadata.change_type} 与文件名=${filenameType} 不一致`));
|
||||
}
|
||||
if (/\{[^{}\n]+\}|\bTODO\b|待补充/i.test(body)) {
|
||||
errors.push(ruleError('E_PLACEHOLDER', file, '正文仍有 TODO、待补充或模板占位符'));
|
||||
}
|
||||
if (!body.includes('## 变更接口') || !body.includes('## 验证证据')) {
|
||||
errors.push(ruleError('E_SECTIONS', file, '正文缺少“变更接口”或“验证证据”章节'));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function collectChangedDocuments(records) {
|
||||
return records
|
||||
.filter(({ status }) => status !== 'D')
|
||||
.map((record) => ({
|
||||
path: record.targetPath,
|
||||
isNew: record.status === 'A' || /^C\d{1,3}$/.test(record.status) || /^R\d{1,3}$/.test(record.status),
|
||||
}))
|
||||
.filter(({ path: file }) => controlledRootForPath(file) && file.endsWith('.md'));
|
||||
}
|
||||
|
||||
export function runFrontmatterValidation(records, root = process.cwd()) {
|
||||
const documents = collectChangedDocuments(records);
|
||||
const errors = [];
|
||||
for (const document of documents) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(path.join(root, ...document.path.split('/')), 'utf8');
|
||||
} catch (error) {
|
||||
errors.push(ruleError('E_READ', document.path, `无法读取文件: ${error.message}`));
|
||||
continue;
|
||||
}
|
||||
errors.push(...validateV2Document(document.path, text, { requireV2: document.isNew }));
|
||||
}
|
||||
return { checkedCount: documents.length, errors };
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
try {
|
||||
const options = parseArguments(argv);
|
||||
const records = parseNameStatusZ(diffFromOptions(options));
|
||||
const result = runFrontmatterValidation(records);
|
||||
if (result.errors.length > 0) {
|
||||
for (const error of result.errors) {
|
||||
console.error(`[${error.code}] ${error.path}: ${error.message}`);
|
||||
}
|
||||
console.error(`FAIL: ${result.errors.length} frontmatter error(s) in ${result.checkedCount} changelog file(s).`);
|
||||
return 1;
|
||||
}
|
||||
console.log(`PASS: validated frontmatter for ${result.checkedCount} changed changelog file(s).`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
|
||||
if (isCli) {
|
||||
process.exitCode = main();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
parseFrontmatter,
|
||||
runFrontmatterValidation,
|
||||
validateFrontendState,
|
||||
validateFrontendTransition,
|
||||
validateV2Document,
|
||||
} from '../scripts/validate-changelog-frontmatter.mjs';
|
||||
|
||||
|
||||
const FILE = 'changelogs-v2/2026-07/24_5218_工作流治理-修改接口-管理后台.md';
|
||||
|
||||
function metadata(overrides = {}) {
|
||||
return {
|
||||
schema: 'hl-changelog/v2',
|
||||
ticket: '5218',
|
||||
title: '工作流治理',
|
||||
consumer: 'admin',
|
||||
change_type: '修改接口',
|
||||
backend_status: 'deployed',
|
||||
gateway_status: 'verified',
|
||||
frontend_status: 'pending',
|
||||
frontend_owner: '',
|
||||
frontend_ref: '',
|
||||
target_release: '',
|
||||
verified_at: '',
|
||||
status_note: '',
|
||||
updated_at: '2026-07-24',
|
||||
base: 'dev-v3',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function document(overrides = {}) {
|
||||
const fields = metadata(overrides);
|
||||
const frontmatter = Object.entries(fields)
|
||||
.map(([key, value]) => `${key}: "${value}"`)
|
||||
.join('\n');
|
||||
return `---\n${frontmatter}\n---\n\n# 工作流治理\n\n## 变更接口\n\n- 无业务接口变化。\n\n## 验证证据\n\n- 自动化测试通过。\n`;
|
||||
}
|
||||
|
||||
test('parses quoted flat YAML frontmatter', () => {
|
||||
const parsed = parseFrontmatter(document());
|
||||
assert.equal(parsed.metadata.schema, 'hl-changelog/v2');
|
||||
assert.equal(parsed.metadata.frontend_status, 'pending');
|
||||
});
|
||||
|
||||
test('accepts a complete v2 handoff with pending frontend consumption', () => {
|
||||
assert.deepEqual(validateV2Document(FILE, document(), { requireV2: true }), []);
|
||||
});
|
||||
|
||||
test('rejects backend and gateway pending at publication', () => {
|
||||
const errors = validateV2Document(
|
||||
FILE,
|
||||
document({ backend_status: 'pending', gateway_status: 'pending' }),
|
||||
{ requireV2: true },
|
||||
);
|
||||
assert.ok(errors.some(({ code }) => code === 'E_BACKEND_PENDING'));
|
||||
assert.ok(errors.some(({ code }) => code === 'E_GATEWAY_PENDING'));
|
||||
});
|
||||
|
||||
test('requires frontend evidence as status advances', () => {
|
||||
assert.deepEqual(
|
||||
validateFrontendState(metadata({ frontend_status: 'claimed' })),
|
||||
['claimed 必须填写 frontend_owner'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
validateFrontendState(metadata({
|
||||
frontend_status: 'implemented',
|
||||
frontend_owner: 'frontend-team',
|
||||
frontend_ref: 'mmg/hl-ui@abc1234',
|
||||
})),
|
||||
[],
|
||||
);
|
||||
assert.ok(
|
||||
validateFrontendState(metadata({
|
||||
frontend_status: 'verified',
|
||||
frontend_owner: 'frontend-team',
|
||||
frontend_ref: 'mmg/hl-ui@abc1234',
|
||||
target_release: 'prod-2026.07.24',
|
||||
verified_at: '2026-02-30',
|
||||
})).includes('verified_at 必须是 ISO 日期或时间'),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects skipped transitions and requires a rollback reason', () => {
|
||||
assert.ok(validateFrontendTransition('pending', 'implemented').length > 0);
|
||||
assert.deepEqual(validateFrontendTransition('pending', 'claimed'), []);
|
||||
assert.ok(validateFrontendTransition('released', 'implemented').length > 0);
|
||||
assert.deepEqual(
|
||||
validateFrontendTransition('released', 'implemented', '测试发布已回滚'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('new changelog requires v2 while a modified legacy file remains compatible', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'hl-frontmatter-'));
|
||||
try {
|
||||
const file = path.join(root, ...FILE.split('/'));
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeFileSync(file, '# legacy\n');
|
||||
const newResult = runFrontmatterValidation([{ status: 'A', targetPath: FILE }], root);
|
||||
assert.ok(newResult.errors.some(({ code }) => code === 'E_FRONTMATTER'));
|
||||
const modifiedResult = runFrontmatterValidation([{ status: 'M', targetPath: FILE }], root);
|
||||
assert.deepEqual(modifiedResult.errors, []);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('detects metadata and filename mismatches', () => {
|
||||
const errors = validateV2Document(
|
||||
FILE,
|
||||
document({ ticket: '9999', change_type: '新增接口' }),
|
||||
{ requireV2: true },
|
||||
);
|
||||
assert.ok(errors.some(({ code }) => code === 'E_TICKET_MISMATCH'));
|
||||
assert.ok(errors.some(({ code }) => code === 'E_TYPE_MISMATCH'));
|
||||
});
|
||||
|
||||
test('rejects unsupported consumers and impossible dates', () => {
|
||||
const errors = validateV2Document(
|
||||
FILE,
|
||||
document({ consumer: 'browser', updated_at: '2026-02-30' }),
|
||||
{ requireV2: true },
|
||||
);
|
||||
assert.ok(errors.some(({ code }) => code === 'E_CONSUMER'));
|
||||
assert.ok(errors.some(({ code }) => code === 'E_UPDATED_AT'));
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户