diff --git a/changelogs/2026-04/2026-04-17_product-v2_daily-mileage-step5-validation.md b/changelogs/2026-04/2026-04-17_product-v2_daily-mileage-step5-validation.md new file mode 100644 index 0000000..140c21c --- /dev/null +++ b/changelogs/2026-04/2026-04-17_product-v2_daily-mileage-step5-validation.md @@ -0,0 +1,177 @@ +# 修复:daily-mileage 节点过滤 & Step5 上架预检必填校验 + +> **服务**: hl-product-service-v2 (端口 8083) +> **PR**: #754 +> **Issue**: #751 +> **日期**: 2026-04-17 +> **影响范围**: Step2 行程编排里程试算、Step5 补充信息上架预检 + +--- + +## 问题 1(P1 阻塞):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 时必填**;其他节点允许为空 | + +--- + +## 问题 3:Step5 上架预检补必填校验 + +### 现象 +产品 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。 diff --git a/changelogs/2026-04/2026-04-17_refund-policy-apply-pay-type.md b/changelogs/2026-04/2026-04-17_refund-policy-apply-pay-type.md new file mode 100644 index 0000000..207e81c --- /dev/null +++ b/changelogs/2026-04/2026-04-17_refund-policy-apply-pay-type.md @@ -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 一起回传)