# 二期 v3：资源退费说明 CRUD 模块（给司机行程单展示用）

> **服务**: hl-resource-service
> **PR**: #3273
> **Issue**: #3272
> **日期**: 2026-05-30
> **影响**: 🟢 新增能力。给运营在景区/活动详情下"维护退费说明"提供 CRUD 接口；最终用于行程单 PDF 渲染 + 核单 Step 5 录入对账。本期**仅交付资源服务端**，后续 PR 接入产品快照 + PDF + 核单。

---

## 总览（前端 mmg 必读）

每个景区（SCENIC）和活动（ACTIVITY）资源都可以挂一份"退费说明"，包含资源级备注 + 多条退费明细（标题/金额/单位/备注/生效期）。

**界面**：景区 / 活动列表行加一个按钮 → 弹窗里调本期 3 个接口做 CRUD。

**接口前缀**：`/admin/refund-note`（**无 `/v3/` 前缀**，因 resource-service 沿用一期路径风格；消费方仍是二期 v3 管理后台）。

**本期不做**：行程单 PDF 渲染、产品 ProductDetailVO 注入 refundNote、订单 OrderProductSnapshotContent 反序列化、核单 Step 5 录入对接。这些留后续 PR。

---

## 接口清单（3 个）

### 1. 查询单资源的退费说明

```
GET /admin/refund-note?resourceType=SCENIC&resourceId=12345
```

**入参**（query）

| 字段 | 类型 | 必填 | 枚举 | 说明 |
|---|---|---|---|---|
| `resourceType` | string | ✅ | `SCENIC` / `ACTIVITY` | 资源类型 |
| `resourceId` | long | ✅ | - | 资源 ID（字符串透传雪花）|

**返回**

```jsonc
{
  "code": 200,
  "data": {                              // 未配置时直接返 null,前端按 null 隐藏弹窗内容
    "noteId": "20596378108368322580",   // 雪花 ID 字符串透传
    "resourceType": "SCENIC",
    "resourceId": "12345",
    "intro": "苔藓为赠送项目，不退费",
    "items": [
      {
        "title": "成人未参加",
        "amount": 44.00,
        "unitLabel": "/人",                  // 展示文案，给人看
        "settleScope": "PER_PERSON",         // 结算粒度枚举，给规则引擎用
        "remark": "",
        "effectiveFrom": null,              // yyyy-MM-dd
        "effectiveTo": null
      },
      ...
    ],
    "createTime": "2026-05-30 10:15:00",
    "updateTime": "2026-05-30 10:15:00"
  }
}
```

**枚举**

- `settleScope`：
  - `PER_PERSON` 按人结算（默认）
  - `PER_TEAM` 按团结算（如寻龙诀 100/团 整团一次性）
  - `PER_VEHICLE` 按车辆结算（如卡丁车 2 人/辆，按辆数乘单价）

**注意**：`unitLabel`（"/人" / "/团" / "/辆"）和 `settleScope` **职责不同**：
- 卡丁车场景：`unitLabel="/人"`（给客户看是 120/人）+ `settleScope=PER_VEHICLE`（引擎按车辆数乘单价）
- 两个字段独立维护，前端展示用 `unitLabel`，未来规则引擎用 `settleScope`。

---

### ⚡ 更新（2026-06-01 PR #3291）：settleScope 字典化 + 出参补 settleScopeLabel 中文

后端**已落字典 + 接口出参直接返中文**，前端**不用自己维护英文→中文映射**。两种用法二选一：

#### 用法 A：直接用出参的 `settleScopeLabel`（展示场景推荐）

`GET /admin/refund-note` 返回的 `items[]` 每条**新增 `settleScopeLabel` 字段**：

```jsonc
"items": [
  {
    "title": "成人未参加",
    "settleScope": "PER_PERSON",
    "settleScopeLabel": "按人",      // 🆕 后端字典拼装,直接展示
    ...
  }
]
```

**列表 / 详情 UI 直接 `{{ item.settleScopeLabel }}` 渲染**即可，无需自己映射。

#### 用法 B：调字典接口拉下拉框选项（编辑场景推荐）

管理后台编辑退费规则时，需要下拉框让运营选"按人/按团/按车"。调统一字典接口：

```http
GET /internal/dict/data?dictType=order_refund_settle_scope
Authorization: Bearer <admin-token>

→ Result<List<SysDictDataDTO>>:
[
  { "dictValue": "PER_PERSON",  "dictLabel": "按人",  "sortOrder": 10 },
  { "dictValue": "PER_TEAM",    "dictLabel": "按团",  "sortOrder": 20 },
  { "dictValue": "PER_VEHICLE", "dictLabel": "按车",  "sortOrder": 30 }
]
```

- 下拉框 `option.label = dictLabel`，`option.value = dictValue`
- 提交时 `req.items[].settleScope = dictValue`（提交 `settleScopeLabel` 后端会忽略，label 由后端拼）
- 建议前端**全局缓存字典 5-10 分钟**避免高频请求；如果有统一字典组件直接复用

#### 容错（前端可忽略）

- 字典服务异常 / 字典数据缺失 → `settleScopeLabel = null`，前端展示 fallback 用 `settleScope` 英文原值即可
- 字典文案后续改了（如"按人" → "按人头"）→ **运维改 sys_dict_data + 清 Redis 缓存即生效**，后端无需重启，前端无需发版

#### 字典元信息

| 项 | 值 |
|---|---|
| `dict_type` | `order_refund_settle_scope` |
| `dict_name` | 退费明细结算粒度 |
| 字典数据 | PER_PERSON→按人 / PER_TEAM→按团 / PER_VEHICLE→按车 |
| 字典存储 | `hl_user_service.sys_dict_data` |
| 缓存 key | `cache:dict:data:order_refund_settle_scope` |

### 2. 保存（upsert 整块）

```
PUT /admin/refund-note
```

**入参**（body）

```jsonc
{
  "resourceType": "SCENIC",
  "resourceId": 12345,
  "intro": "苔藓为赠送项目，不退费",
  "items": [
    {
      "title": "成人未参加",      // 必填
      "amount": 44.00,           // 必填，>= 0（赠送项填 0）
      "unitLabel": "/人",         // 默认 "/人"，可省略
      "settleScope": "PER_PERSON", // 默认 "PER_PERSON"，可省略
      "remark": "",
      "effectiveFrom": null,
      "effectiveTo": null
    }
  ]
}
```

**约束**

- `resourceType`：必须是 `SCENIC` / `ACTIVITY`
- `resourceId`：**必须在 scenic_spot / activity 表存在且未软删**，否则返 390805 "关联资源不存在"
- `items`：至少 1 条，最多 50 条
- `items[].amount`：>= 0（赠送项目填 0）
- `items[].settleScope`：必须是上述 3 枚举之一（null 时默认 PER_PERSON）
- `items[].effectiveFrom` <= `items[].effectiveTo`（同时存在时）

**返回**

```jsonc
{ "code": 200, "data": "20596378108368322580" }   // 落库后的 noteId（字符串透传）
```

**语义**：upsert——按 (resourceType, resourceId) 找现有记录，有则**整块覆盖**（不做 item 级 diff），无则 insert。

### 3. 软删整份

```
DELETE /admin/refund-note?resourceType=SCENIC&resourceId=12345
```

**入参**：同 GET。
**返回**：`{ "code": 200, "data": true }`（不存在或已软删返 false，不报错）。

软删用主键自身写 deleted_at，UNIQUE KEY 永不撞键，支持同资源无限次"删→重建"。

---

## 错误码（段位 39080x）

| code | 错误信息 |
|---|---|
| 390801 | 退费明细金额必须 ≥ 0 |
| 390802 | 退费明细生效起日不能晚于止日 |
| 390803 | 退费明细结算粒度非法: {0} |
| 390804 | 退费说明资源类型非法: {0} |
| 390805 | 关联资源不存在: type={0}, id={1} |

非业务错误（参数校验失败）走通用 400。

---

## 业务边界

- **资源类型**：一期仅 `SCENIC` + `ACTIVITY`，其他资源（餐饮/酒店/物资等）有各自退订/退款政策，不复用本结构。
- **一份生效**：同资源同时刻只有一份生效的退费说明（DB 唯一键 `(resource_type, resource_id, deleted_at)` 保证）。
- **关联校验**：保存时校验 resourceId 在主资源表存在，防孤儿数据。
- **资源软删后**：本退费说明仍存在但孤儿（查不到对应资源），不主动清理；运营侧需手动删除。

---

## 数据示例（典型）

### 白桦林（4 条规则）

```jsonc
{
  "resourceType": "SCENIC", "resourceId": 12345,
  "intro": "苔藓为赠送项目, 不退费",
  "items": [
    {"title": "儿童/学生/无证件未参加", "amount": 15, "unitLabel": "/人", "settleScope": "PER_PERSON"},
    {"title": "免票座电瓶车",         "amount": 30, "unitLabel": "/人", "settleScope": "PER_PERSON"},
    {"title": "白桦林未参加",         "amount": 44, "unitLabel": "/人", "settleScope": "PER_PERSON"},
    {"title": "桦树皮画未参加",       "amount": 50, "unitLabel": "/人", "settleScope": "PER_PERSON", "remark": "仅儿童"}
  ]
}
```

### 寻龙诀（按团结算）

```jsonc
{
  "resourceType": "ACTIVITY", "resourceId": 99999,
  "items": [
    {"title": "寻龙诀未参加", "amount": 100, "unitLabel": "/团", "settleScope": "PER_TEAM"}
  ]
}
```

### 卡丁车（按车结算，文案 /人）

```jsonc
{
  "resourceType": "ACTIVITY", "resourceId": 88888,
  "items": [
    {"title": "卡丁车未骑", "amount": 120, "unitLabel": "/人", "settleScope": "PER_VEHICLE",
     "remark": "2 人/辆共享单价"}
  ]
}
```

### 套娃（时间窗口）

```jsonc
{
  "resourceType": "SCENIC", "resourceId": 77777,
  "items": [
    {"title": "老人只看大马戏", "amount": 65,  "settleScope": "PER_PERSON"},
    {"title": "6/25 后没去或免票", "amount": 165, "settleScope": "PER_PERSON",
     "effectiveFrom": "2026-06-25", "effectiveTo": null}
  ]
}
```

---

## 影响评估

- **后端**：仅 hl-resource-service 新增 1 张表 `resource_refund_note` + 3 个 admin 接口，对其他业务零影响。
- **DB**：Flyway `V20260529_002__create_resource_refund_note.sql` 自动建表，重启服务后生效。
- **前端**：新增弹窗 UI（建议在景区 / 活动详情页加按钮 → 弹窗 CRUD），完全独立的新页面，老页面不影响。

---

## 注意事项

1. 雪花 ID 透传为字符串（`noteId` / `resourceId`），前端**不要 `Number()`**。
2. 资源软删后的退费说明不会自动清理，运营侧需先删退费说明再删资源（否则成孤儿数据）。
3. 同资源不可有 2 份生效退费说明（DB 唯一键约束）。
4. 删除是软删，可同资源重建；不支持物理删除。

---

## 关联

- **Issue**: [#3272](https://git.1814.love:8443/wx/HL/issues/3272)
- **PR**: [#3273](https://git.1814.love:8443/wx/HL/pulls/3273) - feat(resource): 新增资源退费说明 CRUD 模块
- **Commit**: [ffae5ab90](https://git.1814.love:8443/wx/HL/commit/ffae5ab90)
- **后续 PR**（不在本期）：
  - 产品服务 `ProductDetailVO.NodeItem.refundNote` 注入（参考 #3147 serviceStandard 模式）
  - 订单 `OrderProductSnapshotContent` 加 refundNote 反序列化（10 行）
  - 行程单 PDF 渲染退费表（订单 v3 + PDF 服务）
  - 核单 Step 5 录入半结构化对接（可选）
