diff --git a/changelogs/2026-04/2026-04-20_hotel-district-filter.md b/changelogs/2026-04/2026-04-20_hotel-district-filter.md new file mode 100644 index 0000000..78f71d6 --- /dev/null +++ b/changelogs/2026-04/2026-04-20_hotel-district-filter.md @@ -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 查实际酒店数据,不是独立字典表