- 新增数据库迁移脚本,为详情表添加四个价格相关字段 - 完善后端schema校验、序列化逻辑,增加价格相关的数据清理与验证规则 - 新增价格配置校验逻辑,设置起售价时必须选择对应的计价单位 - 在Admin UI详情编辑器中新增价格配置模块,支持配置起售价、计价单位、适用人数范围和价格说明 - 更新小程序端详情页面,支持展示配置的参考价格信息 - 补充相关测试用例,更新API文档与类型定义
381 lines
15 KiB
Markdown
381 lines
15 KiB
Markdown
# 详情展示管理 Admin API
|
||
|
||
> 适用端:`WonderQ-Admin`、`WonderQ-Admin-UI-Vue`。
|
||
>
|
||
> 状态:已实现契约。本文件定义独立路线详情展示模型,供 `WonderQ-Admin`、`WonderQ-Admin-UI-Vue` 和 `WonderQ-MiniAPP` 三端联调使用。详情不恢复 Product、ProductImage 或订单关联。
|
||
|
||
## 领域边界
|
||
|
||
详情管理只维护详情页可编辑的展示内容:
|
||
|
||
- 详情页识别键、标题眉标、出行时长和标题文案。
|
||
- 详情介绍、行程亮点、费用包含、费用不含和注意事项。
|
||
- 详情页图片画廊及图片顺序。
|
||
- 详情页参考价格:价格起始值、单位、适用人数范围和价格说明。
|
||
- 详情页可选的联系管家顾问 ID;顾问资料仍由管家领域维护。
|
||
- 启用状态和详情列表顺序。
|
||
|
||
本接口不负责:
|
||
|
||
- 商品、商品价格、商品库存或商品详情表。
|
||
- 订单或预订价格计算;本接口中的价格仅用于路线详情展示。
|
||
- Product、ProductImage 或任何商品外键。
|
||
- 订单、预订、收藏、评价或线索。
|
||
- 管家顾问的头像、二维码、服务详情和管家 CRUD;详情只保存顾问 ID。
|
||
|
||
`key` 固定使用玩法路线 ID(即服务端生成的 `WanfaRoute.id` UUID),但 `DetailRecord` 不建立数据库外键。详情页通过 `/pages/detail/index?routeId={key}` 定位内容;详情记录删除不会跨领域级联删除路线或其他数据。
|
||
|
||
所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。
|
||
|
||
## 与 `detailPresentation.ts` 的关系
|
||
|
||
`detailPresentation.ts` 是前台展示适配器,不是持久化模型:
|
||
|
||
- `eyebrow`、`duration`、`title`、`subtitle`、`intro`、`highlights`、`included`、`excluded`、`notes`、`gallery` 组成最终展示对象。
|
||
- `priceStartingValue`、`priceUnit`、`pricePeopleRange`、`priceDescription` 组成可选的参考价格展示对象。
|
||
- 当前实现优先消费 Public API 返回的最终展示字段。
|
||
- 接口失败、字段不完整或详情未配置时,MiniAPP 按路线 ID 使用本地网络图片和模拟文案兜底。
|
||
|
||
新的 Admin API 应直接维护最终展示字段;`WonderQ-Admin-UI-Vue` 不应复刻这些推导逻辑,也不应依赖已移除的 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` 或省略字段。
|
||
- 成功结果放入 `data`;失败返回数字 `code`、`msg`、`data: null`,可选 `errorCode` 和 `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[];
|
||
priceStartingValue: number | null;
|
||
priceUnit: "person" | "day" | "group" | null;
|
||
pricePeopleRange: string;
|
||
priceDescription: string;
|
||
};
|
||
|
||
type DetailRecord = DetailPresentation & {
|
||
id: string;
|
||
key: string;
|
||
conciergeAdvisorId: string | null;
|
||
isActive: boolean;
|
||
sortOrder: number;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
};
|
||
|
||
type PublicDetail = Omit<DetailPresentation, "key"> & {
|
||
key: string;
|
||
conciergeAdvisor: PublicConciergeAdvisor | null;
|
||
};
|
||
|
||
type PublicConciergeAdvisor = {
|
||
avatar: string;
|
||
name: string;
|
||
role: string;
|
||
details: Array<{ icon: string; label: string }>;
|
||
qrImage: string;
|
||
};
|
||
|
||
type DetailCreate = Omit<DetailPresentation, "priceStartingValue" | "priceUnit" | "pricePeopleRange" | "priceDescription"> & {
|
||
key: string;
|
||
priceStartingValue?: number | null;
|
||
priceUnit?: "person" | "day" | "group" | null;
|
||
pricePeopleRange?: string | null;
|
||
priceDescription?: string | null;
|
||
conciergeAdvisorId?: string | null;
|
||
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 张以匹配当前前台逻辑。 |
|
||
| `priceStartingValue` | `number \| null` | 否 | 参考起始值,单位为万元;未配置时为 `null`,不参与订单或报价计算。 |
|
||
| `priceUnit` | `"person" \| "day" \| "group" \| null` | 否 | 价格展示单位,分别对应“人”“天”“团”;未配置价格时为 `null`。 |
|
||
| `pricePeopleRange` | `string` | 否 | 适用人数范围,例如“2-4人”;仅用于展示。 |
|
||
| `priceDescription` | `string` | 否 | 价格补充说明,例如“价格以最终确认方案为准”。 |
|
||
| `conciergeAdvisorId` | `string \| null` | 否 | 关联的管家顾问 ID;不建立数据库外键,空字符串保存为 `null`。 |
|
||
| `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
|
||
{
|
||
"code": 200,
|
||
"msg": "success",
|
||
"data": {
|
||
"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"],
|
||
"priceStartingValue": 1.68,
|
||
"priceUnit": "person",
|
||
"pricePeopleRange": "2-4人",
|
||
"priceDescription": "价格以最终确认方案为准。",
|
||
"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>
|
||
```
|
||
|
||
成功返回 `data` 内的单个 `DetailRecord`。记录不存在返回 `404`,业务码为 `DETAIL_NOT_FOUND`。
|
||
|
||
### 新增详情
|
||
|
||
```http
|
||
POST /api/admin/details
|
||
Authorization: Bearer <admin-jwt>
|
||
Content-Type: application/json
|
||
```
|
||
|
||
请求体为 `DetailCreate`。成功返回 `201` 和 `data` 内新建的 `DetailRecord`;未传 `sortOrder` 时追加到当前列表末尾。`conciergeAdvisorId` 传空字符串时归一化为 `null`,传入不存在的顾问 ID 返回 `422 DETAIL_CONCIERGE_ADVISOR_INVALID`。
|
||
|
||
### 编辑详情
|
||
|
||
```http
|
||
PATCH /api/admin/details/{detailId}
|
||
Authorization: Bearer <admin-jwt>
|
||
Content-Type: application/json
|
||
```
|
||
|
||
请求体为 `DetailPatch`,只更新提交的字段。修改 `key` 时仍须保证全局唯一。成功返回 `data` 内更新后的 `DetailRecord`;记录不存在返回 `404`,业务码为 `DETAIL_NOT_FOUND`,`key` 冲突返回 `409`,业务码为 `DETAIL_KEY_EXISTS`。
|
||
|
||
### 删除详情
|
||
|
||
```http
|
||
DELETE /api/admin/details/{detailId}
|
||
Authorization: Bearer <admin-jwt>
|
||
```
|
||
|
||
成功返回:
|
||
|
||
```json
|
||
{
|
||
"code": 200,
|
||
"msg": "success",
|
||
"data": { "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
|
||
{
|
||
"code": 200,
|
||
"msg": "success",
|
||
"data": { "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[];
|
||
conciergeAdvisor: PublicConciergeAdvisor | null;
|
||
};
|
||
```
|
||
|
||
不存在、停用或未配置详情时返回 `404`。详情未配置顾问、顾问不存在或顾问已停用时,详情仍正常返回,但 `conciergeAdvisor` 为 `null`。MiniAPP 请求失败或字段不完整时,按 `key` 查找本地 fallback;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` 和 `GET /api/admin/concierge/advisors`;详情编辑器嵌入现有路线编辑抽屉,不新增侧边菜单。
|
||
2. 新增和编辑表单维护眉标、时长、标题、副标题、介绍、价格配置和四组列表文案;保存路线时以已保存的路线 ID 作为详情 `key`。
|
||
3. `highlights`、`included`、`excluded`、`notes` 使用可增删的重复字段编辑器,提交时保留数组顺序,不拼接成换行字符串。
|
||
4. 使用图片上传接口维护 `gallery`,支持新增、删除和调整图片顺序。
|
||
5. 在详情图片后维护价格起始值、价格单位、适用人数范围和价格说明;价格未配置时提交空值,不能写入前台固定价格。
|
||
6. 上移或下移详情时提交完整详情 ID 列表,不直接修改本地 `sortOrder` 后假设保存成功。
|
||
7. 删除前要求二次确认;删除成功后以接口返回或重新查询的数据更新列表。
|
||
8. 处理 `401`、`404`、`409`、`422` 和 `5xx`,保存、上传或排序进行中禁用重复提交。
|
||
9. 详情编辑器不得出现 Product ID、ProductImage ID、库存、订单或预订字段;联系管家使用顾问 ID 选择器;详情保存失败时明确提示路线摘要已保存、详情需要重试。
|
||
|
||
建议的 Admin UI API 封装函数:
|
||
|
||
```ts
|
||
getDetails();
|
||
getDetail(detailId: string);
|
||
createDetail(input: DetailCreate);
|
||
updateDetail(detailId: string, input: DetailPatch);
|
||
deleteDetail(detailId: string);
|
||
reorderDetails(itemIds: string[]);
|
||
```
|
||
|
||
## 与前台展示模型的映射
|
||
|
||
Public API 返回的 `PublicDetail` 可以直接映射为 MiniAPP 的 `DetailPresentation`;Admin API 返回的 `DetailRecord` 只包含 `conciergeAdvisorId`,不复制管家资料:
|
||
|
||
```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,
|
||
priceStartingValue: publicDetail.priceStartingValue,
|
||
priceUnit: publicDetail.priceUnit,
|
||
pricePeopleRange: publicDetail.pricePeopleRange,
|
||
priceDescription: publicDetail.priceDescription,
|
||
conciergeAdvisor: publicDetail.conciergeAdvisor ?? null,
|
||
};
|
||
```
|
||
|
||
接入时应优先使用接口已保存的最终文案和图片顺序;本地 fallback 只用于接口失败、字段不完整或详情未配置的前台容错。
|
||
|
||
## 后端落地边界
|
||
|
||
当前实现由 `WonderQ-Admin` 的 `DetailRecord` 模型、Admin/Public 路由和审计日志提供能力;由 `WonderQ-Admin-UI-Vue` 在玩法路线编辑抽屉中维护详情及顾问 ID;由 `WonderQ-MiniAPP` 调用 Public 详情接口并按路线 ID fallback。顾问资料始终从管家领域实时读取,不复制到详情表。
|
||
|
||
相关文档:
|
||
|
||
- [Admin API 主契约](./admin-api-requirements.md)
|
||
- [管家管理 Admin API](./concierge-api.md)
|
||
- [详情展示适配器](../WonderQ-MiniAPP/src/pages/detail/components/detailPresentation.ts)
|