feat(hotel): 管理端区/县字段与字典接口(后端 PR #984 + #988)

- GET /admin/hotel 新增 district 查询参数(精确匹配)
- 新增 GET /admin/hotel/districts?city=... 字典接口(distinct district)
- GET /admin/hotel/items 返回新增 district 字段(补 HotelListVO)

前端需改:HotelPickerModal 删除 city distinct 伪造逻辑改调字典接口;资源管理酒店列表顶栏加区/县下拉。
这个提交包含在:
API Changelog Bot 2026-04-20 15:59:00 +08:00
父节点 bd7dcf5f3b
当前提交 eb81927845

查看文件

@ -0,0 +1,238 @@
# 管理端酒店 - 区/县筛选能力(`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 查实际酒店数据,不是独立字典表