# 管理端酒店 - 区/县筛选能力（`district` 字段 + 字典接口）

- **日期**: 2026-04-20
- **PR**: [#984](https://git.1814.love:8443/wx/HL/pulls/984) (Closes #982) + [#988](https://git.1814.love:8443/wx/HL/pulls/988)（补 HotelListVO.district）
- **类型**: FEATURE + BUGFIX（补齐后端能力，修复管理端区/县筛选不可用）
- **服务**: hl-resource-service（`/admin/hotel/**`）
- **前端是否需要改动**: **需要改动**（两处：资源管理-酒店列表顶栏、产品编辑-HotelPickerModal）

---

## 一、背景

管理端两处区/县筛选当前不可用：

| # | 入口 | 前端现状 |
|---|------|---------|
| 1 | 资源管理 - 酒店列表页 | 顶栏只有城市文本输入框，没有区/县下拉 |
| 2 | 产品编辑 - 添加酒店弹窗（`HotelPickerModal.vue`） | 有区/县下拉，但选项是从已加载的 hotelList 的 `city` distinct 伪造（逻辑错误：city 和 district 不是同一维度），选中后还把值当 `city` 参数传 |

根因：后端 `HotelQueryRequest` 缺 `district` 字段（即便前端传了也会被忽略），也没有字典接口给前端做下拉数据源。

本次后端补齐两件事，让前端可以正常按区/县筛选。

---

## 二、变更接口

| # | 方法 | 路径 | 变更类型 |
|---|------|------|---------|
| 1 | GET | `/admin/hotel` | **新增** `district` 查询参数（可选，精确匹配） |
| 2 | GET | `/admin/hotel/districts` | **新增接口**：按 city 列出该城市下已上架酒店的 distinct 区/县列表 |

---

## 三、接口 1：列表查询新增 `district` 参数

### 请求参数变化

| 参数 | 类型 | 之前 | 现在 | 说明 |
|------|------|------|------|------|
| `city` | String | 可选 | 可选（不变） | 城市精确匹配（模糊匹配走 `cityKeyword`） |
| `district` | String | ❌ 不存在 | **新增，可选** | 区/县精确匹配（最大 32 字符） |
| 其他参数 | — | 不变 | 不变 | `keyword` / `cityKeyword` / `hotelType` / `starLevel` / `status` / `tagId` / `tagIds` / `sortBy` / `sortDir` / `page` / `pageSize` 等全部不变 |

### 调用示例

```js
// 原用法：只按城市筛选（兼容，不变）
GET /admin/hotel?city=海拉尔&page=1&pageSize=20

// 新用法：city + district 组合筛选
GET /admin/hotel?city=海拉尔&district=海拉尔区&page=1&pageSize=20
```

### 兼容性

老前端不传 `district` 完全兼容，行为不变。

---

## 四、接口 2：新增 区/县 字典接口

### 签名

```
GET /admin/hotel/districts?city={city}
```

### 请求参数

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `city` | String | **可选** | 城市精确匹配；不传则返所有已上架酒店的 distinct district |

### 响应结构

```json
{
  "code": 200,
  "data": [
    { "value": "海拉尔区", "label": "海拉尔区" },
    { "value": "陈巴尔虎旗", "label": "陈巴尔虎旗" },
    { "value": "额尔古纳市", "label": "额尔古纳市" }
  ]
}
```

`value` 和 `label` 都是区/县名称，按区/县名称升序。

### 数据来源

- 表：`hotel`
- SQL 等价：
```sql
SELECT DISTINCT district FROM hotel
WHERE status = 1
  AND deleted_at IS NULL
  AND district IS NOT NULL
  AND district != ''
  [AND city = ?]
ORDER BY district ASC
```

### 过滤规则

- 只返"已上架"酒店（`status = 1`）的 district
- 已软删除的酒店不计入
- district 为 `NULL` 或空字符串的被过滤
- `city` 传空 → 跨城市聚合；`city` 传值 → 只看该城市

---

## 五、前端改造建议

### 场景 A：产品编辑 - HotelPickerModal.vue

**改动点**：
1. 删除 L66-69 从 `hotelList` 里 distinct `city` 伪造 `areaOptions` 的代码
2. 改为调 `GET /admin/hotel/districts?city=xxx` 拉取下拉数据（建议在选中 city 时触发）
3. 选中区/县后，查询参数名从 `city` 改为 **`district`**（`city` 由 city 选择器单独负责）

```js
// 区/县选项加载（city 变化时触发）
async function loadAreaOptions(city) {
  if (!city) { areaOptions.value = []; return }
  const res = await request({
    url: '/admin/hotel/districts',
    method: 'GET',
    data: { city }
  })
  areaOptions.value = res.data  // [{ value, label }]
}

// 查询酒店（修正参数名）
const params = {
  city: cityFilter.value,            // 城市
  district: areaFilter.value,        // 🆕 区/县，之前错用 city
  keyword: keyword.value,
  page: 1,
  pageSize: 20
}
```

### 场景 B：资源管理 - 酒店列表页

**改动点**：
1. 顶栏 `city` 输入框右侧加"区/县"级联下拉
2. city 改变时 reset 区/县并重新拉字典
3. 查询参数同步传 `district`

---

## 六、列表响应新增 district 字段

`GET /admin/hotel/items` 的 records 每一项新增 `district` 字段，前端表格可直接展示区/县列。其他字段保持不变。

```json
{
  "hotelId": "3001000000000000001",
  "name": "XXX 酒店",
  "city": "呼伦贝尔市",
  "district": "海拉尔区",   // 🆕 新增
  "hotelType": "HOTEL",
  "starLevel": "FIVE_STAR",
  // ... 其他字段不变
}
```

## 七、响应 VO

### `HotelDistrictOptionVO`

```java
{
  "value": "海拉尔区",   // 区/县名称（与 label 相同）
  "label": "海拉尔区"    // 区/县名称
}
```

简单键值对，便于直接丢到 Element UI / Ant Design 的下拉组件。

---

## 八、不兼容变更

**无**。列表接口只是新增可选参数，字典接口是全新路径，老前端零影响。

---

## 九、回归验证（已在测试环境实测）

- ✅ `/admin/hotel/districts` 无 city → 返 4 个值
- ✅ `/admin/hotel/districts?city=呼伦贝尔市` → 返 3 个值（city 精确匹配生效）
- ✅ `/admin/hotel/items` records 37/39 含 district 字段（2 个脏数据是 null 属正常）
- ✅ `/admin/hotel/items?district=海拉尔区` → 8 条过滤生效

---

## 十、自行验证

测试环境部署完成后，用 admin token 验证：

```bash
# 取 token
TOKEN=$(curl -s "https://api.test.1814.love/admin/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"username":"test_admin","password":"Test@2026"}' | jq -r .data.token)

# 1. 字典接口（带 city）
curl "https://api.test.1814.love/admin/hotel/districts?city=海拉尔" \
  -H "Authorization: Bearer $TOKEN" | jq .

# 2. 字典接口（不带 city，跨城市聚合）
curl "https://api.test.1814.love/admin/hotel/districts" \
  -H "Authorization: Bearer $TOKEN" | jq '.data | length'

# 3. 列表接口带 district
curl "https://api.test.1814.love/admin/hotel?city=海拉尔&district=海拉尔区&page=1&pageSize=5" \
  -H "Authorization: Bearer $TOKEN" | jq '.data.records[] | {name, city, district}'

# 4. 列表接口老用法（不带 district，兼容验证）
curl "https://api.test.1814.love/admin/hotel?city=海拉尔&page=1&pageSize=5" \
  -H "Authorization: Bearer $TOKEN" | jq '.data.total'
```

**预期**：
- 场景 1：返回该城市下已上架酒店的 distinct district 列表
- 场景 2：返回所有城市聚合后的 distinct district
- 场景 3：返回该城市该区的酒店
- 场景 4：行为与改动前完全一致

---

## 十一、不涉及的

- **小程序端（`/internal/mp/hotel/**`）不变**：本次只改管理端，小程序端若后续要区/县筛选走独立 PR
- **DB schema 不变**：`hotel.district VARCHAR(32)` 本来就有
- **字典维护页不需要新建**：数据源是 distinct 查实际酒店数据，不是独立字典表
