Files
WonderQ-Project/docs/detail-api.md
T
duanshuwen c143b67275 feat(route-detail): 实现路线详情独立管理全链路功能
添加`DetailRecord`数据库模型、迁移脚本及完整的CRUD管理接口,将路线详情从玩法路线表解耦,实现独立存储。
在Admin UI的路线编辑器中集成详情配置面板,支持编辑详情文案、图片等展示内容。
完善MiniAPP路线详情页,新增导航函数、API调用及数据归一化逻辑,并添加对应单元测试。
更新所有相关文档,明确领域边界、联调流程及接口契约规范。
调整首页和玩法页的点击跳转逻辑,优先跳转路线详情页而非原需求页。
新增数值格式化工具函数优化内容展示效果。
2026-08-19 21:20:51 +08:00

328 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 详情展示管理 Admin API
> 适用端:`WonderQ-Admin`、`WonderQ-Admin-UI`。
>
> 状态:已实现契约。本文件定义独立路线详情展示模型,供 `WonderQ-Admin`、`WonderQ-Admin-UI` 和 `WonderQ-MiniAPP` 三端联调使用。详情不恢复 Product、ProductImage 或订单关联。
## 领域边界
详情管理只维护详情页可编辑的展示内容:
- 详情页识别键、标题眉标、出行时长和标题文案。
- 详情介绍、行程亮点、费用包含、费用不含和注意事项。
- 详情页图片画廊及图片顺序。
- 启用状态和详情列表顺序。
本接口不负责:
- 商品、商品价格、商品库存或商品详情表。
- Product、ProductImage 或任何商品外键。
- 订单、预订、收藏、评价或线索。
- 详情页底部的电话、管家联系和预订动作。
`key` 固定使用玩法路线 ID(即 `WanfaRoute.id`,例如 `family-water`),但 `DetailRecord` 不建立数据库外键。详情页通过 `/pages/detail/index?routeId={key}` 定位内容;详情记录删除不会跨领域级联删除路线或其他数据。
## 与 `detailPresentation.ts` 的关系
`detailPresentation.ts` 是前台展示适配器,不是持久化模型:
- `eyebrow`、`duration`、`title`、`subtitle`、`intro`、`highlights`、`included`、`excluded`、`notes`、`gallery` 组成最终展示对象。
- 当前实现优先消费 Public API 返回的最终展示字段。
- 接口失败、字段不完整或详情未配置时,MiniAPP 按路线 ID 使用本地网络图片和模拟文案兜底。
新的 Admin API 应直接维护最终展示字段;Admin UI 不应复刻这些推导逻辑,也不应依赖已移除的 Product 字段。
## 接口清单
API 前缀为 `/api/admin`,除登录接口外均需要后台 JWT。
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/api/admin/details` | 获取全部详情展示配置 |
| `GET` | `/api/admin/details/{detailId}` | 获取单个详情展示配置 |
| `POST` | `/api/admin/details` | 新增详情展示配置 |
| `PATCH` | `/api/admin/details/{detailId}` | 编辑详情展示配置 |
| `DELETE` | `/api/admin/details/{detailId}` | 删除详情展示配置 |
| `PATCH` | `/api/admin/details/reorder` | 调整详情展示配置顺序 |
## 通用约定
- 请求和响应使用 JSON,字段使用 camelCase。
- 所有管理接口需要 `Authorization: Bearer <admin-jwt>`。
- `GET /details` 返回启用和停用的全部配置,按 `sortOrder` 升序返回。
- 创建、编辑、删除和排序成功后写入审计日志,再提交事务。
- 创建和编辑返回最新详情对象;排序接口返回排序后的 `items`。
- ID 由后端生成并作为非空字符串返回;`key` 由调用方提供并保持稳定。
- 空列表返回 `[]`,不能返回 `null` 或省略字段。
- 失败响应沿用 Admin API 约定,包含 `message`、`code` 和可选的 `details`。
## 数据类型
以下内容字段与 `DetailPresentation` 保持兼容,管理端补充 `id`、`key`、状态和审计时间:
```ts
type DetailPresentation = {
key: string;
eyebrow: string;
duration: string;
title: string;
subtitle: string;
intro: string;
highlights: string[];
included: string[];
excluded: string[];
notes: string[];
gallery: string[];
};
type DetailRecord = DetailPresentation & {
id: string;
key: string;
isActive: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
};
type PublicDetail = Omit<DetailPresentation, "key"> & {
key: string;
};
type DetailCreate = DetailPresentation & {
key: string;
isActive?: boolean;
sortOrder?: number;
};
type DetailPatch = Partial<DetailCreate>;
type DetailListResponse = {
details: DetailRecord[];
};
type DetailReorderRequest = {
itemIds: string[];
};
```
## 字段约束
| 字段 | 类型 | 必填 | 约束和用途 |
| --- | --- | --- | --- |
| `id` | `string` | 响应必填 | 后端生成的记录 ID,仅供管理端识别记录。 |
| `key` | `string` | 是 | 玩法路线 ID,例如 `family-water`;必须唯一,不建立数据库外键。 |
| `eyebrow` | `string` | 是 | 详情页顶部眉标,例如“玩法推荐”。 |
| `duration` | `string` | 是 | 展示用时长,例如“5天4晚”;接口保存最终文案,不要求前端从标题正则提取。 |
| `title` | `string` | 是 | 详情页主标题。 |
| `subtitle` | `string` | 是 | 详情页副标题或目的地说明。 |
| `intro` | `string` | 是 | “详细介绍”区域的主介绍文案。 |
| `highlights` | `string[]` | 是 | “行程亮点”列表,保留数组顺序。 |
| `included` | `string[]` | 是 | “费用包含”列表,保留数组顺序。 |
| `excluded` | `string[]` | 是 | “费用不含”列表,保留数组顺序。 |
| `notes` | `string[]` | 是 | “注意事项”列表,保留数组顺序。 |
| `gallery` | `string[]` | 是 | 详情图片 URL 列表,按展示顺序返回;建议最多 6 张以匹配当前前台逻辑。 |
| `isActive` | `boolean` | 响应必填 | 是否进入已发布前台内容,创建默认 `true`。 |
| `sortOrder` | `number` | 响应必填 | 非负整数,数值越小越靠前;创建时未传则追加到末尾。 |
| `createdAt` | `string` | 响应必填 | ISO 8601 创建时间。 |
| `updatedAt` | `string` | 响应必填 | ISO 8601 最后更新时间。 |
服务端应校验 `key` 唯一、文本字段去除首尾空白后不为空、数组元素为非空字符串、`gallery` 为 URL 列表、`sortOrder` 为非负整数。具体文本最大长度由后端 schema 统一定义,并同步到 Admin UI 表单校验。
## 接口详情
### 获取详情列表
```http
GET /api/admin/details
Authorization: Bearer <admin-jwt>
```
成功响应:
```json
{
"details": [
{
"id": "detail-001",
"key": "classic-panorama",
"eyebrow": "玩法推荐",
"duration": "5天4晚",
"title": "经典贵州全景",
"subtitle": "贵州·瀑布、苗寨、古城与山地风光",
"intro": "沿着贵州山地的自然纹理深入探索。",
"highlights": ["核心景观串联", "小团出行,按同行人节奏调整"],
"included": ["行程内用车与接送服务"],
"excluded": ["往返大交通及个人消费"],
"notes": ["贵州多山多雨,请准备防滑鞋和轻便雨具。"],
"gallery": ["https://example.test/assets/detail-01.jpg"],
"isActive": true,
"sortOrder": 0,
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z"
}
]
}
```
### 获取单个详情
```http
GET /api/admin/details/{detailId}
Authorization: Bearer <admin-jwt>
```
成功返回单个 `DetailRecord`。记录不存在返回 `404 DETAIL_NOT_FOUND`。
### 新增详情
```http
POST /api/admin/details
Authorization: Bearer <admin-jwt>
Content-Type: application/json
```
请求体为 `DetailCreate`。成功返回 `201` 和新建的 `DetailRecord`;未传 `sortOrder` 时追加到当前列表末尾。
### 编辑详情
```http
PATCH /api/admin/details/{detailId}
Authorization: Bearer <admin-jwt>
Content-Type: application/json
```
请求体为 `DetailPatch`,只更新提交的字段。修改 `key` 时仍须保证全局唯一。成功返回更新后的 `DetailRecord`;记录不存在返回 `404 DETAIL_NOT_FOUND`,`key` 冲突返回 `409 DETAIL_KEY_EXISTS`。
### 删除详情
```http
DELETE /api/admin/details/{detailId}
Authorization: Bearer <admin-jwt>
```
成功返回:
```json
{ "id": "detail-001" }
```
删除后应重新规范化剩余记录的 `sortOrder`,从 `0` 开始连续编号。详情记录没有 Product、订单或线索外键,因此删除不触发跨领域级联操作。
### 调整详情顺序
```http
PATCH /api/admin/details/reorder
Authorization: Bearer <admin-jwt>
Content-Type: application/json
```
请求必须完整包含当前全部详情 ID,不能重复:
```json
{ "itemIds": ["detail-002", "detail-001"] }
```
成功返回:
```json
{ "items": [] }
```
其中 `items` 为更新 `sortOrder` 后的 `DetailRecord[]`。缺少 ID、包含未知 ID 或出现重复 ID 时返回 `400 DETAIL_REORDER_INVALID`。
## MiniAPP Public API
### 获取路线详情
```http
GET /api/public/details/{key}
```
无需鉴权。`key` 为 `WanfaRoute.id`,例如 `family-water`。接口只返回启用且已配置的展示字段,不返回管理端 ID、状态、排序和审计时间:
```ts
type PublicDetail = {
key: string;
eyebrow: string;
duration: string;
title: string;
subtitle: string;
intro: string;
highlights: string[];
included: string[];
excluded: string[];
notes: string[];
gallery: string[];
};
```
不存在、停用或未配置详情时返回 `404`。MiniAPP 请求失败或字段不完整时,按 `key` 查找本地 fallback;找不到 fallback 时展示未找到和重试状态。该页面暂不提供价格、收藏、在线订阅、预订、订单或管家联系动作。
## 图片与素材
Admin UI 使用现有素材上传接口获取图片 URL:
```http
POST /api/admin/media-assets/upload
```
建议详情页图片使用 `group=detail`,将返回的 `url` 按用户排列顺序写入 `gallery`。接口只保存图片 URL,不创建 ProductImage 表或商品图片关联。
当前 `detailPresentation.ts` 的 fallback 图片属于前台兜底逻辑。Admin API 正式接入后,Admin UI 不应把 fallback 图片自动写入数据库;应由运营人员明确上传和排序详情图片。
## Admin UI 对接要求
Admin UI 应按以下方式调用:
1. 玩法页加载时同时调用玩法分类和 `GET /api/admin/details`;详情编辑器嵌入现有路线编辑抽屉,不新增侧边菜单。
2. 新增和编辑表单维护眉标、时长、标题、副标题、介绍和四组列表文案;保存路线时以已保存的路线 ID 作为详情 `key`。
3. `highlights`、`included`、`excluded`、`notes` 使用可增删的重复字段编辑器,提交时保留数组顺序,不拼接成换行字符串。
4. 使用图片上传接口维护 `gallery`,支持新增、删除和调整图片顺序。
5. 上移或下移详情时提交完整详情 ID 列表,不直接修改本地 `sortOrder` 后假设保存成功。
6. 删除前要求二次确认;删除成功后以接口返回或重新查询的数据更新列表。
7. 处理 `401`、`404`、`409`、`422` 和 `5xx`,保存、上传或排序进行中禁用重复提交。
8. 详情编辑器不得出现 Product ID、ProductImage ID、库存、订单、价格或预订字段;详情保存失败时明确提示路线摘要已保存、详情需要重试。
建议的 Admin UI API 封装函数:
```ts
getDetails();
getDetail(detailId: string);
createDetail(input: DetailCreate);
updateDetail(detailId: string, input: DetailPatch);
deleteDetail(detailId: string);
reorderDetails(itemIds: string[]);
```
## 与前台展示模型的映射
Admin API 返回的 `DetailRecord` 可以映射为 `DetailPresentation`:
```ts
const presentation: DetailPresentation = {
key: record.key,
eyebrow: record.eyebrow,
duration: record.duration,
title: record.title,
subtitle: record.subtitle,
intro: record.intro,
highlights: record.highlights,
included: record.included,
excluded: record.excluded,
notes: record.notes,
gallery: record.gallery,
};
```
接入时应优先使用接口已保存的最终文案和图片顺序;本地 fallback 只用于接口失败、字段不完整或详情未配置的前台容错。
## 后端落地边界
当前实现由 `WonderQ-Admin` 的 `DetailRecord` 模型、`0021_detail_records` 迁移、Admin/Public 路由和审计日志提供能力;由 `WonderQ-Admin-UI` 在玩法路线编辑抽屉中维护详情;由 `WonderQ-MiniAPP` 调用 Public 详情接口并按路线 ID fallback。迁移文件只负责生成和初始化详情记录,不自动执行数据库升级。
相关文档:
- [Admin API 主契约](./admin-api-requirements.md)
- [管家管理 Admin API](./concierge-api.md)
- [详情展示适配器](../WonderQ-MiniAPP/src/pages/detail/components/detailPresentation.ts)