feat: 补充 daily-mileage 节点过滤 & Step5 必填 & 退款政策支付类型过滤 changelog

这个提交包含在:
API Changelog Bot 2026-04-17 18:11:05 +08:00
父节点 f2e30e51ac
当前提交 eae2f907eb
共有 2 个文件被更改,包括 418 次插入0 次删除

查看文件

@ -0,0 +1,177 @@
# 修复daily-mileage 节点过滤 & Step5 上架预检必填校验
> **服务**: hl-product-service-v2 (端口 8083)
> **PR**: #754
> **Issue**: #751
> **日期**: 2026-04-17
> **影响范围**: Step2 行程编排里程试算、Step5 补充信息上架预检
---
## 问题 1P1 阻塞Step2 行程编排「计算每日里程」接口 400
### 现象
前端在 Step2 行程编排点"下一步",后端对节点列表里 `resourceId` 为空的自由活动节点返回:
```json
{
"code": 400,
"message": "days[0].nodes[1].resourceId: resourceId不能为空"
}
```
### 根因
`DailyMileageCalcReqVO.NodeRef.resourceId` 挂了 `@NotNull`,但业务上 `FREE/NOTE/TRANSPORT/CUSTOM/PHOTOGRAPHY` 等节点本就不需要 resourceId它们没有对应的景区/活动/酒店资源)。
### 修复
- 移除 `@NotNull``required=true`
- 接口层面接受任何节点类型传入;Service 层按节点类型白名单自动过滤,不抛错
---
## 问题 2业务规则距离计算节点范围收窄 + 酒店去重
### 业务规则
每日驾车距离/时间计算只取:**景区SCENIC+ 游玩项目ACTIVITY+ 当天第一个酒店HOTEL**。
### 规则前后对比
| 节点类型 | 旧逻辑 | 新逻辑 |
|----------|--------|--------|
| SCENIC | ✅ 参与 | ✅ 参与 |
| ACTIVITY | ✅ 参与 | ✅ 参与 |
| HOTEL | ✅ 全部参与 | ⚠️ **每天只保留 sortOrder 最小的那 1 个** |
| RESTAURANT | ✅ 参与 | ❌ **不参与** |
| SERVICE | ✅ 参与 | ❌ **不参与** |
| TRANSPORT / FREE / CUSTOM / NOTE / PHOTOGRAPHY | ❌ 不参与 | ❌ 不参与(无变化) |
举例:当天节点序列 `景区1 → 餐厅 → 景区2 → 酒店A → 酒店B → 活动1`
- 旧6 点参与 → `dist(景1→餐→景2→店A→店B→活1)`
- 新4 点参与 → `dist(景1→景2→店A→活1)`(餐厅/酒店B 过滤)
### 影响接口
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/admin/product/item/{id}/daily-mileage` | Step3 实时试算(前端传节点序列) |
| GET | `/admin/product/item/{id}/mileage` | 总里程(基于保存后的节点) |
| GET | `/admin/product/item/{id}/daily-mileage-saved` | 基于保存后节点的按天里程 |
### 请求/响应契约
**请求字段(无变化,仅校验收紧/放宽)**
```json
{
"days": [
{
"dayNumber": 1,
"nodes": [
{"nodeType": "SCENIC", "resourceId": 3001},
{"nodeType": "FREE"}, // resourceId 可空
{"nodeType": "RESTAURANT", "resourceId": 4001},
{"nodeType": "HOTEL", "resourceId": 5001},
{"nodeType": "HOTEL", "resourceId": 5002} // 同日第二个酒店 → 过滤
]
}
]
}
```
**响应结构(无变化)**
```json
{
"code": 200,
"success": true,
"data": [
{"dayNumber": 1, "mileage": 42.35, "duration": 3600}
]
}
```
### 字段规范
| 字段 | 类型 | 说明 |
|------|------|------|
| `days[].dayNumber` | Integer | 天序号,从 1 开始 |
| `days[].nodes[].nodeType` | String | 节点类型(字典 `product_node_type``SCENIC / ACTIVITY / HOTEL / RESTAURANT / SERVICE / TRANSPORT / FREE / CUSTOM / NOTE` |
| `days[].nodes[].resourceId` | Long | **只在 SCENIC/ACTIVITY/HOTEL 时必填**;其他节点允许为空 |
---
## 问题 3Step5 上架预检补必填校验
### 现象
产品 Step5 补充信息里的「保险方案」「合同方案」「行程保障模板」即使没选,**`validatePublish` 也不报 issue**,导致产品能被提交上架但实际缺字段。
### 修复
**`GET /admin/product/item/{id}/validate-publish`** 的 L4 条款校验阶段,新增 3 条 issue
| 条件 | 新增 issue 文案 |
|------|-----------------|
| `insuranceNotice``{INCLUDED, OPTIONAL}``insuranceSchemeId == null` | `"保险方案未选择"` |
| `contractSchemeId == null`(不论保险类型) | `"合同方案未选择"` |
| `guaranteeTemplateId == null`(不论保险类型) | `"行程保障模板未选择"` |
### 响应示例(缺字段时)
```json
{
"code": 200,
"success": true,
"data": [
"保险方案未选择",
"合同方案未选择",
"行程保障模板未选择"
]
}
```
### 保存草稿接口保持宽松
- `PUT /admin/product/item/{id}/supplement` **不新增校验** —— 继续允许分步填写(空字段能保存)
- 校验只发生在**上架预检**阶段
### 字典 insurance_notice
| Code | 说明 |
|------|------|
| `INCLUDED` | 含保险(必选保险方案) |
| `OPTIONAL` | 可选保险(必选保险方案) |
| `EXCLUDED` | 不含保险(可不选保险方案) |
---
## 前端适配
### Step2 行程编排
- **无需改动**。前端传原样节点序列即可;后端自动按节点类型过滤不参与计算的节点。
- 旧行为:"自由活动"节点进入里程接口时 400 —— 已修复,不再会出现。
### Step3 实时里程试算
- 如果前端之前有"过滤掉餐厅/服务"的代码,**可以去除**(后端已处理)。不去除也无影响(重复过滤 = 过滤)。
### Step5 上架预检
- 前端调 `validate-publish` 时,错误列表里会**新出现**如下 3 个文案,请前端错误映射里补上:
- `"保险方案未选择"`
- `"合同方案未选择"`
- `"行程保障模板未选择"`
- 表现为"点上架 → 后端返回这些 issue → 前端提示用户去 Step5 补充"。
---
## 单元测试
- `AmapDrivingServiceTest`: 21 条(含"同日多酒店取首"、"餐厅/服务过滤"、"跨天多酒店各自取首"三条新增)
- `ProductValidationServiceTest`: 14 条(含 insuranceScheme / contractScheme / guaranteeTemplate 缺失各 1 条)
## 测试环境验证
`api.test.1814.love:9443` 实测通过:
| 用例 | 结果 |
|------|------|
| `POST /daily-mileage` 混入 FREE 节点 | 200 ✅ |
| `POST /daily-mileage` 同日 3 个 HOTEL | 200按规则只取首个✅ |
| `POST /daily-mileage` 含 RESTAURANT+SERVICE | 200已过滤✅ |
| `GET /validate-publish` 缺 3 字段 | 3 条 issue ✅ |
## 重启提示
需要重启 `hl-product-service-v2`(端口 8083 + 8183—— 通过 Deploy Panel 重新部署最新 jar。

查看文件

@ -0,0 +1,241 @@
# 新增退款政策按产品支付类型FULL/DEPOSIT/BOTH过滤
> **服务**: hl-order-service-v2 (端口 8094) + hl-product-service-v2 (端口 8083)
> **PR**: #755
> **Issue**: #752
> **日期**: 2026-04-17
> **影响范围**: 管理端退款政策下拉、产品 Step5 预订须知保存、产品上架预检
---
## 背景
Step5 预订须知的「退款政策下拉」与产品 Step1 的 `paymentType` 无关联:定金产品也能选到"全款退款政策",语义错位,订单退款计算时可能走错规则。
## 设计要点
- `refund_policy` 表新增 `apply_pay_type` 字段(复用字典 `payment_type`,扩容 `BOTH` 枚举值)
- 下拉接口加可选 `payType` 参数按类型过滤;**不传 = 全量返回**(老前端页面兼容不改)
- 保存 supplement 时后端做硬校验:不匹配直接拦截
- 上架预检 `validate-publish` 也做一次匹配校验(防止 refund_policy 被改过后绕过)
---
## 字段扩展
### `RefundPolicyVO` / `RefundPolicyRequest` / Feign `RefundPolicyDTO` 全部新增字段
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `applyPayType` | String | 否 | 适用支付类型(字典 `payment_type``FULL`=全款 / `DEPOSIT`=定金 / `BOTH`=两者均可)。创建时未传默认 `BOTH` |
### 字典 `payment_type`(本次扩容 BOTH
| Code | 说明 | 用途 |
|------|------|------|
| `FULL` | 全款 | 产品支付类型 + 退款政策适用类型 |
| `DEPOSIT` | 定金 | 产品支付类型 + 退款政策适用类型 |
| `BOTH` | 两者均可 | **仅用于退款政策**,表示对 FULL/DEPOSIT 产品均适用 |
> 注意:`product_basic.payment_type` 只会是 `FULL``DEPOSIT``BOTH` 只出现在 `refund_policy.apply_pay_type`
---
## 接口变更
### 1. 退款政策下拉(加可选过滤参数)
**`GET /admin/order/refund-policy/enabled`**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `payType` | String | 否 | 产品支付类型。仅允许 `FULL` / `DEPOSIT`(传 `BOTH` 会报 400 |
**过滤规则**
- 不传 → 返回全部启用政策(向后兼容)
- `payType=FULL` → 返回 `apply_pay_type ∈ {FULL, BOTH}` 的政策
- `payType=DEPOSIT` → 返回 `apply_pay_type ∈ {DEPOSIT, BOTH}` 的政策
- `payType=INVALID` 或其他值 → `code=400` + `message="支付类型仅支持 FULL/DEPOSIT"`
**响应示例**
```json
{
"code": 200,
"success": true,
"data": [
{
"policyId": 2,
"policyName": "全款默认退款政策",
"enabled": true,
"applyPayType": "FULL",
"remark": "全款退款默认策略",
"rules": [
{"ruleId": "...", "minDays": 7, "refundRatio": 100},
{"ruleId": "...", "minDays": 3, "refundRatio": 50},
{"ruleId": "...", "minDays": 1, "refundRatio": 0}
]
}
]
}
```
### 2. 退款政策详情 / 列表 / 批量
这几个接口**不变签名**,但响应 VO 自动带出 `applyPayType` 字段:
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/admin/order/refund-policy/{policyId}` | 详情 |
| GET | `/admin/order/refund-policy/page` | 管理端列表 |
| GET | `/internal/order/refund-policy/batch?policyIds=...` | Feign 批量(产品服务内部调用) |
### 3. 退款政策创建 / 修改
**`POST /admin/order/refund-policy` / `PUT /admin/order/refund-policy/{policyId}`**
Body 新增可选字段:
```json
{
"policyName": "我的退款政策",
"applyPayType": "FULL", // 可选FULL/DEPOSIT/BOTH;不传兜底 BOTH
"enabled": true,
"remark": "...",
"rules": [...]
}
```
- 校验:`@Pattern(^(FULL|DEPOSIT|BOTH)$)`
- 未传时服务端默认写 `BOTH`
- 更新时**无条件覆盖**(如果只传了 policyName 未传 applyPayType,会被覆盖为 `BOTH`。如需保留原值,前端编辑时务必把原 `applyPayType` 回传)
### 4. 产品补充信息保存 —— 新增前置阻断
**`PUT /admin/product/item/{id}/supplement`**
Body 不变,但**前置校验**:传入的 `refundPolicyId``applyPayType` 必须与产品 `paymentType` 匹配。不匹配直接拦截:
```json
{
"code": 500,
"success": false,
"message": "退款政策支付类型不匹配:产品为 DEPOSIT,政策仅适用于 FULL。请选择匹配的退款政策(productId=xxx, refundPolicyId=xxx)"
}
```
匹配规则:
| 产品 paymentType | 政策 applyPayType | 结果 |
|------------------|-------------------|------|
| FULL | FULL | ✅ 放行 |
| FULL | DEPOSIT | ❌ 阻断 |
| FULL | BOTH | ✅ 放行 |
| DEPOSIT | FULL | ❌ 阻断 |
| DEPOSIT | DEPOSIT | ✅ 放行 |
| DEPOSIT | BOTH | ✅ 放行 |
| 任意 | `refundPolicyId=null` | ✅ 放行(允许草稿不选) |
| paymentType 未填 | 任意 | ✅ 放行(草稿阶段) |
特殊异常:
- 传了 `refundPolicyId` 但政策不存在/已停用 → `"所选退款政策不存在或已停用"`
- 订单服务 Feign 不可达 → `"退款政策服务暂时不可用,请稍后重试"`
### 5. 产品上架预检 —— 新增匹配校验
**`GET /admin/product/item/{id}/validate-publish`**
响应 issues 列表里**可能新增**
```
"退款政策支付类型不匹配:产品为 DEPOSIT,政策仅适用于 FULL。请选择匹配的退款政策(productId=..., refundPolicyId=...)"
```
防止:运营改了 `refund_policy.apply_pay_type` 后,已绑定的产品上架时绕过前置校验。
---
## 前端适配(重要)
### Step5 预订须知页
1. **进入 Step5 时**:用产品当前 `paymentType` 拉下拉
```
GET /admin/order/refund-policy/enabled?payType=FULL
```
2. **Step1 `paymentType` 切换时****清空当前 `refundPolicyId` 并重拉下拉**(否则前端缓存的旧下拉项可能已不匹配新支付类型,保存时 500 报错)
3. **老前端兼容**:如果暂不改造,`GET /enabled` 不传 payType 会返回全量政策,用户选错类型时保存接口会 500 拦截并给出中文错误消息 —— 不会写脏数据。
### 退款政策管理页
1. **列表**:表格新增一列展示 `applyPayType`FULL/DEPOSIT/BOTH
2. **新建/编辑表单**:新增单选项 `applyPayType`,默认 `BOTH`
3. **编辑时**:如果拿到政策详情后用户只改了其他字段未改 applyPayType,**必须把原值一起回传**,否则会被 `BOTH` 覆盖
### 字典值中文映射
```js
const applyPayTypeMap = {
FULL: '仅全款产品',
DEPOSIT: '仅定金产品',
BOTH: '全款/定金均可'
};
```
---
## 部署步骤DDL + 代码)
### DDL必须先于代码部署
```sql
-- 1) 新增字段 + 索引
ALTER TABLE refund_policy
ADD COLUMN apply_pay_type VARCHAR(32) NOT NULL DEFAULT 'BOTH'
COMMENT '适用支付类型(字典payment_type: FULL=全款/DEPOSIT=定金/BOTH=两者均可)'
AFTER enabled;
ALTER TABLE refund_policy ADD INDEX idx_rp_apply_pay_type (apply_pay_type, enabled);
-- 2) 人工确认 policy_id 后再执行
-- SELECT policy_id, policy_name FROM refund_policy WHERE deleted_at IS NULL;
UPDATE refund_policy SET apply_pay_type = 'DEPOSIT' WHERE policy_id IN (<定金默认_ID>) AND deleted_at IS NULL;
UPDATE refund_policy SET apply_pay_type = 'FULL' WHERE policy_id IN (<全款默认_ID>) AND deleted_at IS NULL;
-- 其他历史政策保持 BOTH
```
> **注意**:订单 v2 服务连的是 `hl_order_service_v2` 数据库,**不是** `hl_order_service`(后者是 legacy 老服务)。
### 重启服务
- `hl-order-service-v2`(端口 8094 + 8194
- `hl-product-service-v2`(端口 8083 + 8183—— 因为新增了 Validator + saveSupplement 前置调用
---
## 单元测试
- `RefundQueryServiceTest`: 25 条(含按 payType 过滤、create/update 默认 BOTH
- `AdminRefundPolicyControllerTest`: 6 条(含 /enabled 无参 + 带 FULL 参数两路分流)
- `ProductRefundPolicyValidatorTest`: 12 条(匹配矩阵 FULL/DEPOSIT/BOTH + Feign 异常 + 政策不存在)
- `ProductValidationServiceTest`: 6 条(含退款政策不匹配生成 issue
## 测试环境验证
`api.test.1814.love:9443` 实测通过:
| 用例 | 结果 |
|------|------|
| `/enabled` 无参 | 返回 2 条 + `applyPayType` 字段 ✅ |
| `/enabled?payType=FULL` | 只返"全款默认" ✅ |
| `/enabled?payType=DEPOSIT` | 只返"定金默认" ✅ |
| `/enabled?payType=INVALID` | code=400 + 中文消息 ✅ |
| DEPOSIT 产品 + FULL 政策 | 阻断 ✅ |
| DEPOSIT 产品 + DEPOSIT 政策 | 放行 ✅ |
| FULL 产品 + DEPOSIT 政策 | 阻断 ✅ |
---
## 向后兼容性
- ✅ 老前端不传 `payType` → 全量返回(页面不改可跑)
- ✅ 老前端创建政策不传 `applyPayType` → 服务端默认 `BOTH`
- ✅ Feign `batchGetPolicies` 响应自动带出新字段Jackson 默认忽略未知字段)
- ✅ DDL `NOT NULL DEFAULT 'BOTH'` → 现有数据零阻断
- ⚠️ 老前端编辑政策不传 `applyPayType`**会被覆盖为 BOTH**(如不希望丢原值,前端编辑时需要把详情里的 applyPayType 一起回传)