feat(route-sections): add dynamic route sections

Replace hardcoded homepage featured route groups with a fully managed dynamic system:
- add database models RouteSection and RouteSectionProduct for storing route groups and their associated products
- create Alembic migration 0004_route_sections for the new tables
- extend SiteConfigPatchIn schema with subtitle and productIds fields
- refactor shared site_config utility to load dynamic route sections instead of fixed groups
- implement admin CRUD API with validation for duplicate/conflicting product associations
- update public and admin API documentation to reflect the new system
- add default route section seed data and comprehensive test coverage
This commit is contained in:
duanshuwen
2026-07-02 23:05:36 +08:00
parent 521e501992
commit 72d388a047
10 changed files with 610 additions and 43 deletions

View File

@@ -1,4 +1,4 @@
# WonderQ-Admin-UI Admin API 接口需求
# WonderQ-Admin-UI Admin API 接口需求
本文档用于指导 `WonderQ-Admin` 后端按当前 `WonderQ-Admin-UI` 管理端完成 Admin API 对接。接口需求来源于前端 `src/api.ts``src/App.tsx` 的实际类型、请求封装和页面调用。
@@ -34,7 +34,7 @@ Content-Type: application/json
| `POST /api/admin/products` | 新建商品 | 已覆盖 | 返回完整 Product |
| `PATCH /api/admin/products/{id}` | 编辑商品 | 已覆盖 | 返回完整 Product |
| `GET /api/admin/destinations` | 商品目的地下拉、目的地页 | 已覆盖 | 需要返回别名和商品数 |
| `GET /api/admin/site-config` | 首页/目的地/活动结构维护 | 已覆盖 | 需要包含未启用内容 |
| `GET /api/admin/site-config` | 首页/目的地/活动结构维护 | 已覆盖 | 需要包含未启用内容和已保存的 `routeSections` |
| `PATCH /api/admin/site-config/{module}/{item_id}` | 模块内容编辑 | 已覆盖 | 模块名需保持一致 |
| `GET /api/admin/leads` | 需求线索页 | 已覆盖 | UI 当前不传筛选参数 |
| `PATCH /api/admin/leads/{id}/status` | 线索状态流转 | 已覆盖 | UI 更新后会重新拉列表 |
@@ -58,7 +58,7 @@ type LeadStatus = "new" | "assigned" | "contacted" | "planning" | "won" | "inval
### SiteModule
```ts
type SiteModule = "heroSlides" | "destinations" | "themes" | "ctaBanners";
type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "ctaBanners";
```
## 公共数据结构
@@ -188,6 +188,27 @@ type SiteConfig = {
targetValue?: string | null;
isActive: boolean;
}>;
campaigns: Array<{
id: string;
slug: string;
title: string;
description?: string | null;
coverImage?: string | null;
priceAmount?: number | null;
priceUnit?: string | null;
tags: string[];
status: "draft" | "published";
startsAt?: string | null;
endsAt?: string | null;
}>;
routeSections: Array<{
id: string;
title: string;
subtitle?: string | null;
productIds: string[];
isActive: boolean;
sortOrder: number;
}>;
ctaBanners: Array<{
id: string;
alt: string;
@@ -204,14 +225,28 @@ type SiteConfig = {
```ts
type SiteItemPatch = {
title?: string;
subtitle?: string | null;
kicker?: string;
name?: string;
slug?: string;
region?: string | null;
label?: string;
alt?: string;
image?: string | null;
description?: string | null;
coverImage?: string | null;
priceAmount?: number | null;
priceUnit?: string | null;
tags?: string[];
targetType?: string | null;
targetValue?: string | null;
isHot?: boolean;
isActive?: boolean;
sortOrder?: number;
productIds?: string[];
status?: "draft" | "published";
startsAt?: string | null;
endsAt?: string | null;
};
```
@@ -222,6 +257,8 @@ type SiteItemPatch = {
| `heroSlides` | `title``kicker``image``targetType``targetValue``isActive` |
| `destinations` | `name``image``isActive` |
| `themes` | `label``image``targetType``targetValue``isActive` |
| `campaigns` | `title``description``coverImage``priceAmount``priceUnit``tags``status` |
| `routeSections` | `title``subtitle``productIds``isActive``sortOrder` |
| `ctaBanners` | `alt``image``targetType``targetValue``isActive` |
## 接口明细
@@ -372,9 +409,10 @@ GET /api/admin/site-config
要求:
- 返回 `heroSlides``destinations``themes``ctaBanners` 个模块。
- 返回 `heroSlides``destinations``map``themes``campaigns``routeSections``ctaBanners` 个模块。
- Admin API 需要返回未启用内容Public API 才按发布/启用状态过滤。
- 各模块按 `sortOrder` 升序。
- `routeSections` 返回当前已保存的子分组,包含未启用分组和后台配置的全部 `productIds`;无数据时返回空数组。
### 更新站点配置项
@@ -386,7 +424,7 @@ PATCH /api/admin/site-config/{module}/{item_id}
| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `module` | `SiteModule` | 只能为 `heroSlides``destinations``themes``ctaBanners` |
| `module` | `SiteModule` | 只能为 `heroSlides``destinations``map``themes``campaigns``routeSections``ctaBanners` |
| `item_id` | `string` | 对应模块内容项 ID |
请求体:`SiteItemPatch`
@@ -402,6 +440,26 @@ PATCH /api/admin/site-config/{module}/{item_id}
当前 UI 保存后会重新调用 `GET /api/admin/site-config` 刷新页面,响应体只需保证是合法 JSON。
#### 精选线路 `routeSections`
`routeSections` 是首页“精选线路”的动态运营分组配置,不再限制为固定三组。管理端通过新增、更新、删除和排序接口维护闭环;商品本体仍由 `/api/admin/products` 维护,这里只保存首页分组、标题、副文案、启用状态、排序和关联商品 ID 顺序。
```http
POST /api/admin/site-config/routeSections
PATCH /api/admin/site-config/routeSections/{section_id}
DELETE /api/admin/site-config/routeSections/{section_id}
PATCH /api/admin/site-config/routeSections/reorder
```
新增请求至少包含 `title`,可包含 `subtitle``isActive``sortOrder``productIds``id` 由后端生成,不再使用 `routes` / `routes-outdoor` / `routes-mix` 固定槽位。更新请求可包含 `title``subtitle``isActive``sortOrder``productIds``productIds` 表示该分组关联的线路商品及展示顺序。
后端约束:
- `GET /api/admin/site-config` 返回全部后台分组,包含停用分组和后台配置的全部 `productIds`
- `productIds` 中的商品必须存在,且同一请求内不能重复。
- 同一商品不能同时出现在多个精选线路分组;冲突时返回 `409 ROUTE_SECTION_PRODUCT_CONFLICT`
- `DELETE /api/admin/site-config/routeSections/{section_id}` 只删除分组配置并解除关联,不删除商品本体;删除后后端重新整理剩余分组 `sortOrder`
- `PATCH /api/admin/site-config/routeSections/reorder``itemIds` 必须完整覆盖当前全部分组 ID不能缺失、重复或包含未知 ID。
- `GET /api/public/site-config` 只返回启用分组,且 `productIds` 只包含已发布商品;未发布、归档或不存在的商品不进入 Public 响应。
### 线索列表
```http
@@ -485,6 +543,7 @@ POST /api/admin/reset-guizhou-content
destinations: number;
themes: number;
ctaBanners: number;
routeSections?: number;
products: number;
}
```

View File

@@ -78,14 +78,21 @@
### `RouteSection`
`RouteSection` 用于描述首页“精选线路”下的分组。`经典人文打卡线路``极限山野户外野咖线路``人文+户外综合混搭线路` 等属于“精选线路”的子集,不是独立一级模块
`RouteSection` 用于描述首页“精选线路”下的动态运营分组。后台可按任务新增、编辑、删除和排序分组MiniAPP 不应依赖固定分组 ID只按接口返回的分组顺序和 `productIds` 渲染
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `id` | `string` | 是 | 分组 ID例如 `routes``routes-outdoor``routes-mix` |
| `id` | `string` | 是 | 后端生成的分组 ID客户端只用于列表 key 和商品关联,不作为固定业务枚举 |
| `title` | `string` | 是 | 分组标题 |
| `productIds` | `string[]` | | 分组包含的产品 ID产品详情来自 `/api/public/products``items` |
| `subtitle` | `string \| null` | | 分组副文案 |
| `productIds` | `string[]` | 是 | 该分组包含的产品 ID产品详情来自 `/api/public/products.items` |
| `isActive` | `boolean` | 否 | 是否启用Public API 通常只返回启用分组 |
Public API 输出规则:
- `GET /api/public/site-config` 只返回启用的 `routeSections`
- `routeSections[].productIds` 只包含已发布商品 ID未发布、归档或不存在的商品不得出现在 Public 响应中。
- 动态分组按后台 `sortOrder` 升序返回,`productIds` 的顺序就是用户侧商品卡展示顺序。
- MiniAPP 会按 `productIds` 匹配 `/api/public/products.items[].id`;接口缺失、`routeSections` 为空或没有可匹配商品时回退 `src/content.ts` 的本地精选线路兜底内容。
### `PublicProduct`
| 字段 | 类型 | 必填 | 说明 |
@@ -168,11 +175,7 @@
| 首页模块 | 当前接口归属 | 说明 |
| --- | --- | --- |
| 特价优惠 | `site-config.campaigns` + `/api/public/products` | 当前 Public API 只返回活动元信息不直接返回“特价优惠结果列表”。MiniAPP 若要展示活动线路,可按活动标题、标签或后续扩展的活动产品关联从 `/api/public/products` 中筛选。 |
| 精选线路 | `site-config.routeSections` + `/api/public/products` | `routeSections` 返回分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。 |
| 经典人文打卡线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes`。 |
| 极限山野户外野咖线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-outdoor`。 |
| 人文+户外综合混搭线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-mix`。 |
| 精选线路 | `site-config.routeSections` + `/api/public/products` | `routeSections` 返回动态分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。后台可按任务新增、删除、停用和排序分组,用户侧不假设固定三组。 |
#### 响应示例
```json
@@ -224,19 +227,25 @@
],
"routeSections": [
{
"id": "routes",
"id": "route-section-001",
"title": "经典人文打卡线路",
"productIds": ["8a6e7c4f-0000-4000-9000-000000000001"]
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
"productIds": ["8a6e7c4f-0000-4000-9000-000000000001"],
"isActive": true
},
{
"id": "routes-outdoor",
"id": "route-section-002",
"title": "极限山野户外野咖线路",
"productIds": []
"subtitle": "溶洞、峡谷、漂流、峰林骑行和山野咖啡组合",
"productIds": [],
"isActive": true
},
{
"id": "routes-mix",
"id": "route-section-003",
"title": "人文+户外综合混搭线路",
"productIds": []
"subtitle": "非遗村寨、古城夜游、自然轻探险和精品住宿同程安排",
"productIds": [],
"isActive": true
}
]
}
@@ -414,7 +423,8 @@
- `site-config``products` 会在应用启动时并行请求任一请求失败时MiniAPP 会回退到本地静态内容。
- `products.items` 为空时MiniAPP 会使用本地产品兜底数据。
- “精选线路”由 `site-config.routeSections` 定义分组,由 `/api/public/products.items` 提供产品详情;`经典人文打卡线路``极限山野户外野咖线路``人文+户外综合混搭线路` 是“精选线路”的子集
- “精选线路”由 `site-config.routeSections` 定义动态分组标题、副文案和商品 ID 顺序,由 `/api/public/products.items` 提供产品详情;客户端不依赖固定分组 ID 或固定三组数量
- `routeSections` 缺失、为空或无法匹配到有效商品时MiniAPP 使用 `src/content.ts` 的本地精选线路内容回退。
- “特价优惠”当前没有独立 Public 结果列表字段;`site-config.campaigns` 只提供活动元信息,活动线路需通过产品标签/关键词筛选或后续扩展活动产品关联字段。
- 产品搜索当前主要在前端执行,依赖 `title``tags``destination.name``summary`
- 产品详情页当前使用已加载的产品列表数据;后续可改为进入详情页时请求 `GET /api/public/products/{product_id}`
@@ -425,7 +435,8 @@
-`GET /health` 增加或保留健康检查测试。
-`GET /api/public/site-config` 验证返回 JSON 包含 `heroSlides``destinations``map``themes``ctaBanners``campaigns``routeSections` 数组字段。
-`GET /api/public/site-config` 验证 `routeSections` 表达“精选线路”子分组,并包含 `routes``routes-outdoor``routes-mix` 三个当前约定分组
-`GET /api/public/site-config` 验证 `routeSections` 表达“精选线路”子分组;接口返回当前已配置且启用的分组,未配置时返回空数组并由 MiniAPP 本地内容兜底
-`GET /api/public/site-config` 验证 `routeSections` 只返回启用分组,且 `productIds` 不包含未发布商品。
-`GET /api/public/products` 验证响应结构为 `{ items: [...] }`,并覆盖 `keyword``destinationId``status``take` 参数。
-`GET /api/public/products/{product_id}` 验证 UUID、数字 `sourceId` 和 404 场景。
-`GET /api/public/destinations` 验证只返回启用目的地及别名字段。