# 新增：退款政策按产品支付类型（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 一起回传）
