docs: 统一业务资源ID为服务端生成的稳定UUID

更新所有业务API文档,明确持久化资源的正式ID必须为服务端生成的稳定UUID,本地调试或接口失败时可使用语义ID作为fallback。新增数据库迁移脚本0022_opaque_ids,用于将历史语义ID转换为稳定UUID,并同步外键关联、详情记录的key字段以及审计日志的实体ID引用。新增该迁移的单元测试用例,验证ID替换与关联数据同步的逻辑正确性。调整MiniAPP前端代码,优化导航工具函数的格式,移除废弃函数并修改首页跳转逻辑,使用接口返回的UUID作为详情跳转参数。
This commit is contained in:
duanshuwen committed 2026-08-19 22:28:13 +08:00
1 parent e082bd2d98
commit d3a246a873
14 files changed
+314 -24

No files matched your search

@@ -0,0 +1,210 @@
"""Replace historical semantic IDs with stable opaque UUID strings.
Revision ID: 0022_opaque_ids
Revises: 0021_detail_records
New records already use ``uuid4`` in the ORM. This migration only repairs
historical rows created by seed migrations and keeps all IDs stable after the
conversion. Generating a new ID during serialization would break detail URLs,
reorder requests and foreign-key relationships, so this migration is
intentionally data-oriented and cannot be reversed automatically.
"""
from collections.abc import Iterable
from uuid import UUID, uuid4
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
revision = "0022_opaque_ids"
down_revision = "0021_detail_records"
branch_labels = None
depends_on = None
AUDIT_ENTITY_TABLES = {
"hero_slide": "HeroSlide",
"destination_hero": "DestinationHero",
"demand_hero": "DemandHero",
"demand_feature_card": "DemandFeatureCard",
"vehicle_option": "VehicleOption",
"home_experience": "HomeExperience",
"home_team_building": "HomeTeamBuilding",
"home_wild_archive": "HomeWildArchive",
"wanfa_category": "WanfaCategory",
"wanfa_route": "WanfaRoute",
"detail": "DetailRecord",
"concierge_advisor": "ConciergeAdvisor",
"lead": "Lead",
}
def is_opaque_id(value: object) -> bool:
"""Return whether a persisted ID is already a UUID-like opaque value."""
if not isinstance(value, str) or not value.strip():
return False
try:
UUID(value.strip())
except ValueError:
return False
return True
def build_id_mapping(ids: Iterable[object]) -> dict[str, str]:
"""Build a one-time semantic-to-UUID mapping without touching UUID IDs."""
mapping: dict[str, str] = {}
for raw_id in ids:
if raw_id is None:
continue
old_id = str(raw_id).strip()
if old_id and not is_opaque_id(old_id) and old_id not in mapping:
mapping[old_id] = str(uuid4())
return mapping
def _quote_identifier(identifier: str) -> str:
return '"' + identifier.replace('"', '""') + '"'
def _replace_values(conn, table_name: str, column_name: str, mapping: dict[str, str]) -> None:
if not mapping:
return
table = _quote_identifier(table_name)
column = _quote_identifier(column_name)
statement = sa.text(f"UPDATE {table} SET {column} = :new_id WHERE {column} = :old_id")
for old_id, new_id in mapping.items():
conn.execute(statement, {"old_id": old_id, "new_id": new_id})
def _id_tables(conn) -> list[str]:
inspector = inspect(conn)
return [
table_name
for table_name in inspector.get_table_names()
if table_name != "alembic_version"
and any(column["name"] == "id" for column in inspector.get_columns(table_name))
]
def _id_mappings(conn, table_names: list[str]) -> dict[str, dict[str, str]]:
mappings: dict[str, dict[str, str]] = {}
for table_name in table_names:
rows = conn.execute(
sa.text(f"SELECT {_quote_identifier('id')} FROM {_quote_identifier(table_name)}")
).scalars()
mapping = build_id_mapping(rows)
if mapping:
mappings[table_name] = mapping
return mappings
def collect_fk_specs(foreign_keys, mappings: dict[str, dict[str, str]]) -> list[dict]:
"""Keep enough FK metadata to drop, rewrite and recreate each constraint."""
specs: list[dict] = []
for source_table, foreign_key in foreign_keys:
target_table = foreign_key.get("referred_table")
target_columns = foreign_key.get("referred_columns") or []
source_columns = foreign_key.get("constrained_columns") or []
if not target_table or target_table not in mappings or "id" not in target_columns:
continue
options = foreign_key.get("options") or {}
specs.append(
{
"name": foreign_key.get("name"),
"source_table": source_table,
"source_columns": source_columns,
"target_table": target_table,
"target_columns": target_columns,
"ondelete": options.get("ondelete"),
"onupdate": options.get("onupdate"),
}
)
return specs
def _foreign_key_specs(conn, table_names: list[str], mappings: dict[str, dict[str, str]]) -> list[dict]:
inspector = inspect(conn)
foreign_keys = [
(table_name, foreign_key)
for table_name in table_names
for foreign_key in inspector.get_foreign_keys(table_name)
]
return collect_fk_specs(foreign_keys, mappings)
def _drop_foreign_keys(specs: list[dict]) -> None:
for spec in specs:
if spec["name"]:
op.drop_constraint(spec["name"], spec["source_table"], type_="foreignkey")
def _replace_foreign_keys(conn, specs: list[dict], mappings: dict[str, dict[str, str]]) -> None:
for spec in specs:
target_mapping = mappings[spec["target_table"]]
for source_column, target_column in zip(spec["source_columns"], spec["target_columns"]):
if target_column == "id":
_replace_values(conn, spec["source_table"], source_column, target_mapping)
def _restore_foreign_keys(specs: list[dict]) -> None:
for spec in specs:
if not spec["name"]:
continue
op.create_foreign_key(
spec["name"],
spec["source_table"],
spec["target_table"],
spec["source_columns"],
spec["target_columns"],
ondelete=spec["ondelete"],
onupdate=spec["onupdate"],
)
def _replace_detail_route_keys(conn, table_names: list[str], mappings: dict[str, dict[str, str]]) -> None:
if "DetailRecord" not in table_names:
return
_replace_values(conn, "DetailRecord", "key", mappings.get("WanfaRoute", {}))
def _replace_audit_entity_ids(conn, mappings: dict[str, dict[str, str]]) -> None:
if not mappings.get("AuditLog") and "AuditLog" not in _id_tables(conn):
return
for entity, table_name in AUDIT_ENTITY_TABLES.items():
_replace_values(conn, "AuditLog", "entityId", mappings.get(table_name, {}))
def upgrade() -> None:
if context.is_offline_mode():
return
conn = op.get_bind()
table_names = _id_tables(conn)
mappings = _id_mappings(conn, table_names)
if not mappings:
return
# PostgreSQL checks existing foreign keys immediately. Temporarily remove
# affected constraints, rewrite all IDs and references in one transaction,
# then recreate the same constraints with their original actions.
foreign_key_specs = _foreign_key_specs(conn, table_names, mappings)
_drop_foreign_keys(foreign_key_specs)
_replace_foreign_keys(conn, foreign_key_specs, mappings)
_replace_detail_route_keys(conn, table_names, mappings)
_replace_audit_entity_ids(conn, mappings)
for table_name, mapping in mappings.items():
_replace_values(conn, table_name, "id", mapping)
_restore_foreign_keys(foreign_key_specs)
def downgrade() -> None:
# UUID replacement is intentionally one-way: the original semantic IDs
# are not retained in the database and cannot be reconstructed safely.
pass
@@ -0,0 +1,60 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from uuid import UUID, uuid4
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0022_opaque_ids.py"
SPEC = spec_from_file_location("opaque_ids_migration", MIGRATION_PATH)
assert SPEC and SPEC.loader
MIGRATION = module_from_spec(SPEC)
SPEC.loader.exec_module(MIGRATION)
def test_existing_semantic_ids_are_replaced_with_stable_uuid_values():
existing_uuid = str(uuid4())
mapping = MIGRATION.build_id_mapping(["family-route", existing_uuid])
assert set(mapping) == {"family-route"}
UUID(mapping["family-route"])
assert mapping["family-route"] != "family-route"
def test_uuid_values_are_not_regenerated_during_migration():
existing_uuid = str(uuid4())
assert MIGRATION.is_opaque_id(existing_uuid)
assert not MIGRATION.is_opaque_id("family-route")
assert MIGRATION.build_id_mapping([existing_uuid]) == {}
def test_foreign_key_specs_capture_constraints_for_recreation():
foreign_keys = [
(
"WanfaRoute",
{
"name": "WanfaRoute_categoryId_fkey",
"constrained_columns": ["categoryId"],
"referred_table": "WanfaCategory",
"referred_columns": ["id"],
"options": {"ondelete": "RESTRICT"},
},
)
]
specs = MIGRATION.collect_fk_specs(
foreign_keys,
{"WanfaCategory": {"family-route": "category-uuid"}},
)
assert specs == [
{
"name": "WanfaRoute_categoryId_fkey",
"source_table": "WanfaRoute",
"source_columns": ["categoryId"],
"target_table": "WanfaCategory",
"target_columns": ["id"],
"ondelete": "RESTRICT",
"onupdate": None,
}
]
+7 -6
View File
@@ -4,8 +4,13 @@ type QueryValue = string | number | boolean | undefined | null;
function withQuery(path: string, query: Record<string, QueryValue> = {}) { function withQuery(path: string, query: Record<string, QueryValue> = {}) {
const params = Object.entries(query) const params = Object.entries(query)
.filter(([, value]) => value !== undefined && value !== null && value !== "") .filter(
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) ([, value]) => value !== undefined && value !== null && value !== "",
)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`,
)
.join("&"); .join("&");
return params ? `${path}?${params}` : path; return params ? `${path}?${params}` : path;
} }
@@ -27,10 +32,6 @@ export function goBack(fallback = "/pages/home/index") {
goRoot(fallback); goRoot(fallback);
} }
export function goDemand(destination?: string) {
go("/pages/demand/index", { destination });
}
export function goWildArchives() { export function goWildArchives() {
go("/pages/wild-archives/index"); go("/pages/wild-archives/index");
} }
+4 -12
View File
@@ -2,10 +2,9 @@
<view class="wq-page"> <view class="wq-page">
<view class="wq-phone"> <view class="wq-phone">
<scroll-view scroll-y class="h-screen no-scrollbar" :show-scrollbar="false"> <scroll-view scroll-y class="h-screen no-scrollbar" :show-scrollbar="false">
<HomeHeroCarousel :slides="appState.data.heroSlides" @select="openHero" /> <HomeHeroCarousel :slides="appState.data.heroSlides" />
<HomeWanfaRecommendations :items="homeContentState.playRecommendations" @select="openWanfaRecommendation" />
<HomeExperienceList :items="homeContentState.experiences" @select="openExperience" /> <HomeExperienceList :items="homeContentState.experiences" @select="openExperience" />
<HomeVehicleGrid :vehicle-options="vehicleOptions" @select="goDemand('贵州')" /> <HomeVehicleGrid :vehicle-options="vehicleOptions" @select="openVehicle" />
<HomeTeamBuilding :items="homeContentState.teamBuildings" @select="openTeamBuilding" /> <HomeTeamBuilding :items="homeContentState.teamBuildings" @select="openTeamBuilding" />
<HomeWildArchives :items="homeContentState.wildArchives" @select="openWildArchive" @more="openWildArchives" /> <HomeWildArchives :items="homeContentState.wildArchives" @select="openWildArchive" @more="openWildArchives" />
</scroll-view> </scroll-view>
@@ -21,13 +20,10 @@ import HomeExperienceList from "./components/HomeExperienceList.vue";
import HomeTeamBuilding from "./components/HomeTeamBuilding.vue"; import HomeTeamBuilding from "./components/HomeTeamBuilding.vue";
import HomeWildArchives from "./components/HomeWildArchives.vue"; import HomeWildArchives from "./components/HomeWildArchives.vue";
import HomeVehicleGrid from "./components/HomeVehicleGrid.vue"; import HomeVehicleGrid from "./components/HomeVehicleGrid.vue";
import HomeWanfaRecommendations from "./components/HomeWanfaRecommendations.vue";
import type { HomeExperience } from "./components/homeExperienceData"; import type { HomeExperience } from "./components/homeExperienceData";
import type { HomeTeamBuilding as HomeTeamBuildingItem } from "./components/homeTeamBuildingData"; import type { HomeTeamBuilding as HomeTeamBuildingItem } from "./components/homeTeamBuildingData";
import type { HomeWildArchive } from "./components/homeWildArchivesData"; import type { HomeWildArchive } from "./components/homeWildArchivesData";
import type { HomeWanfaRecommendation } from "./components/homeWanfaRecommendationData";
import { import {
goDemand,
goTeamBuildingDetail, goTeamBuildingDetail,
goWanfaRouteDetail, goWanfaRouteDetail,
goWildArchiveDetail, goWildArchiveDetail,
@@ -42,15 +38,11 @@ onShow(() => {
void loadHomeContent(); void loadHomeContent();
}); });
function openHero(title: string) {
goDemand(title);
}
function openExperience(item: HomeExperience) { function openExperience(item: HomeExperience) {
goDemand(item.demandKeyword); goWanfaRouteDetail(item.id);
} }
function openWanfaRecommendation(item: HomeWanfaRecommendation) { function openVehicle(item: any) {
} }
+1
View File
@@ -20,6 +20,7 @@
- 变更接口写入审计日志后再提交事务。 - 变更接口写入审计日志后再提交事务。
- 成功业务结果统一放在 `data`;创建成功为 HTTP/code `201`。 - 成功业务结果统一放在 `data`;创建成功为 HTTP/code `201`。
- 失败统一返回数字 `code`、用户可读 `msg`、`data: null`,业务错误码放在可选的 `errorCode`。 - 失败统一返回数字 `code`、用户可读 `msg`、`data: null`,业务错误码放在可选的 `errorCode`。
- 所有持久化资源的 `id` 由后端生成稳定 UUID 字符串。Admin UI 必须保存并复用接口返回的 ID,不能根据标题、文案或数组下标自行拼接,也不能假设 ID 是可读 slug。
## 接口清单 ## 接口清单
+8
View File
@@ -24,6 +24,14 @@
`data` 内的业务字段保持各领域文档原有结构不变。也就是说,列表的 `items`、首页的 `experiences`、玩法的 `categories` 等字段都位于响应的 `data` 内,而不是与 `code` 同级。 `data` 内的业务字段保持各领域文档原有结构不变。也就是说,列表的 `items`、首页的 `experiences`、玩法的 `categories` 等字段都位于响应的 `data` 内,而不是与 `code` 同级。
## ID 规范
- 所有持久化资源的 `id` 都是服务端生成的稳定不透明字符串,当前实现统一使用 UUID v4 格式。
- ID 只在记录创建时生成,后续列表、详情、排序、编辑和删除响应必须保持不变;禁止在序列化或每次请求时重新随机生成。
- 玩法路线详情的 `DetailRecord.key` 等于对应的 `WanfaRoute.id`,因此路线 ID 迁移后详情 `key` 必须同步更新。
- 本地 fallback/mock 数据可以继续使用便于阅读的语义 ID,但这些 ID 不代表服务端正式 ID;接口成功后应以 API 返回的 UUID 为准。
- `0022_opaque_ids` 迁移只转换历史非 UUID ID,并同步外键、详情 key 和审计实体引用;新建记录继续由 ORM 默认生成 UUID。
## 成功响应 ## 成功响应
### 查询、更新、排序 ### 查询、更新、排序
+2
View File
@@ -6,6 +6,8 @@
所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。
Admin 顾问记录的 `id` 为服务端生成的稳定 UUID 字符串;MiniAPP Public 响应按现有契约不返回顾问 ID。不要使用姓名、角色或本地 mock ID 作为正式顾问标识。
## 领域边界 ## 领域边界
管家管理只维护管家顾问卡片资料: 管家管理只维护管家顾问卡片资料:
+1 -1
View File
@@ -20,7 +20,7 @@
- 订单、预订、收藏、评价或线索。 - 订单、预订、收藏、评价或线索。
- 详情页底部的电话、管家联系和预订动作。 - 详情页底部的电话、管家联系和预订动作。
`key` 固定使用玩法路线 ID(即 `WanfaRoute.id`,例如 `family-water`),但 `DetailRecord` 不建立数据库外键。详情页通过 `/pages/detail/index?routeId={key}` 定位内容;详情记录删除不会跨领域级联删除路线或其他数据。 `key` 固定使用玩法路线 ID(即服务端生成的 `WanfaRoute.id` UUID),但 `DetailRecord` 不建立数据库外键。详情页通过 `/pages/detail/index?routeId={key}` 定位内容;详情记录删除不会跨领域级联删除路线或其他数据。
所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。
+3 -1
View File
@@ -21,6 +21,8 @@
`demandKeyword` 只是点击卡片后预填需求页的普通字符串,不是商品 ID、路线 ID、订单 ID,也不建立数据库外键。 `demandKeyword` 只是点击卡片后预填需求页的普通字符串,不是商品 ID、路线 ID、订单 ID,也不建立数据库外键。
首页正式接口返回的所有资源 `id` 都是稳定 UUID 字符串。不要把下方 fallback 文件中的语义 ID 当作服务端 ID,也不要在接口序列化时重新生成 ID;首页卡片跳转详情、排序和删除必须复用接口返回的同一 ID。
当前首页通过 `GET /api/public/home` 消费四类内容;三个卡片 mock 数组仍作为接口失败、空响应或字段缺失时的前台 fallback,玩法推荐无本地模拟数据时保持空态。Admin UI 通过本文件列出的 Admin API 维护正式数据。 当前首页通过 `GET /api/public/home` 消费四类内容;三个卡片 mock 数组仍作为接口失败、空响应或字段缺失时的前台 fallback,玩法推荐无本地模拟数据时保持空态。Admin UI 通过本文件列出的 Admin API 维护正式数据。
本文件所有 JSON 示例的业务对象均位于统一响应的 `data` 字段内,完整包裹格式见 [api-response-contract.md](./api-response-contract.md)。 本文件所有 JSON 示例的业务对象均位于统一响应的 `data` 字段内,完整包裹格式见 [api-response-contract.md](./api-response-contract.md)。
@@ -336,7 +338,7 @@ Admin 响应记录包含 `id`、`categoryId`、`categoryLabel`、`routeCount`、
## 当前 fallback 与迁移映射 ## 当前 fallback 与迁移映射
迁移初始数据时应保留以下稳定 ID、字段值和当前数组顺序: 以下语义 ID 仅用于 MiniAPP 本地 fallback 和迁移前的内容识别;执行 `0022_opaque_ids` 后,正式 API 返回对应记录的稳定 UUID,字段值和当前数组顺序保持不变:
### 体验推荐 ### 体验推荐
+8 -1
View File
@@ -93,13 +93,20 @@ MiniAPP 联调重点:
路线详情使用独立 `DetailRecord`,不修改 `WanfaRoute` 表结构,也不建立商品、订单或预订关联。联调顺序如下: 路线详情使用独立 `DetailRecord`,不修改 `WanfaRoute` 表结构,也不建立商品、订单或预订关联。联调顺序如下:
1. 执行数据库迁移,确认 `0021_detail_records` 已创建详情表并为已有路线生成基础记录;生产环境执行前按迁移规范单独确认。 1. 执行数据库迁移,确认 `0021_detail_records` 已创建详情表并为已有路线生成基础记录;确认 `0022_opaque_ids` 已将历史语义 ID 转换为稳定 UUID,并同步玩法外键、详情 `key` 和审计引用。生产环境执行前按迁移规范单独确认。
2. 在 Admin UI 进入“玩法”,编辑路线摘要和详情字段,保存时先保存路线,再以路线 ID 作为 `DetailRecord.key` 创建或更新详情。 2. 在 Admin UI 进入“玩法”,编辑路线摘要和详情字段,保存时先保存路线,再以路线 ID 作为 `DetailRecord.key` 创建或更新详情。
3. 检查 `GET /api/public/wanfa/categories` 仍只返回路线摘要;检查 `GET /api/public/home` 的玩法推荐携带关联路线摘要。 3. 检查 `GET /api/public/wanfa/categories` 仍只返回路线摘要;检查 `GET /api/public/home` 的玩法推荐携带关联路线摘要。
4. 在 MiniAPP 首页玩法推荐或玩法页点击路线,确认跳转 `/pages/detail/index?routeId={routeId}`,并请求 `GET /api/public/details/{routeId}`。 4. 在 MiniAPP 首页玩法推荐或玩法页点击路线,确认跳转 `/pages/detail/index?routeId={routeId}`,并请求 `GET /api/public/details/{routeId}`。
5. 修改 Admin UI 的详情内容后刷新 MiniAPP,确认标题、正文、费用说明、注意事项和画廊更新;停用或删除详情时确认 Public API 返回 `404`。 5. 修改 Admin UI 的详情内容后刷新 MiniAPP,确认标题、正文、费用说明、注意事项和画廊更新;停用或删除详情时确认 Public API 返回 `404`。
6. 关闭后端接口,确认 MiniAPP 按路线 ID 展示本地网络图片和模拟文案,并提示当前为模拟数据;未知路线展示未找到和重试状态。 6. 关闭后端接口,确认 MiniAPP 按路线 ID 展示本地网络图片和模拟文案,并提示当前为模拟数据;未知路线展示未找到和重试状态。
## 稳定 ID 联调检查
1. 执行 `0022_opaque_ids` 后,检查首页、玩法、管家、团队共创和客片案例列表返回的 `id` 均为 UUID 字符串。
2. 从列表复制一个 ID 请求对应详情、排序或删除接口,确认同一 ID 可连续复用,不能每次响应变化。
3. 检查首页玩法推荐的 `categoryId`、路线 `id` 与 `GET /api/public/details/{key}` 的 `key` 关联正确。
4. MiniAPP 本地 fallback 的语义 ID 只在接口失败时使用,不得覆盖接口成功返回的 UUID。
路线详情页当前不包含价格、收藏、在线订阅、预订、订单或管家联系动作。字段和错误约定以 `detail-api.md`、`public-api.md` 为准。 路线详情页当前不包含价格、收藏、在线订阅、预订、订单或管家联系动作。字段和错误约定以 `detail-api.md`、`public-api.md` 为准。
## 接口变更流程 ## 接口变更流程
+1
View File
@@ -7,6 +7,7 @@
- API 前缀:`/api/public`。 - API 前缀:`/api/public`。
- 响应使用 JSON;时间使用 ISO 8601 字符串。 - 响应使用 JSON;时间使用 ISO 8601 字符串。
- 所有 `/health` 和 `/api/public/**` JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 - 所有 `/health` 和 `/api/public/**` JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。
- 所有返回的持久化资源 `id` 都是稳定 UUID 字符串,不是标题、分类名或本地 mock 使用的语义 ID;详情、列表和跳转必须复用同一个 ID。
- H5 本地开发通过 `/api` 代理访问后端。 - H5 本地开发通过 `/api` 代理访问后端。
- 内容接口失败时,MiniAPP 使用 `src/content.ts` 的本地兜底内容。 - 内容接口失败时,MiniAPP 使用 `src/content.ts` 的本地兜底内容。
+2
View File
@@ -5,6 +5,8 @@
所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。
团队共创记录的正式 `id` 为服务端生成的稳定 UUID 字符串;MiniAPP 本地 fallback 可以保留语义 ID,但接口成功后必须以 UUID 作为详情请求参数。
## 领域边界 ## 领域边界
- 团队共创卡片和详情共用一条 `HomeTeamBuilding` 记录。 - 团队共创卡片和详情共用一条 `HomeTeamBuilding` 记录。
+5 -3
View File
@@ -4,12 +4,14 @@
> >
> 状态:已实现契约。本文件参考 `WonderQ-MiniAPP/src/pages/play/components/playData.ts` 定义玩法分类和路线的数据结构,以及管理端需要的查询、维护和排序接口。它不是 MiniAPP Public API 文档。 > 状态:已实现契约。本文件参考 `WonderQ-MiniAPP/src/pages/play/components/playData.ts` 定义玩法分类和路线的数据结构,以及管理端需要的查询、维护和排序接口。它不是 MiniAPP Public API 文档。
当前实现:`WonderQ-Admin` 通过迁移 `0015_wanfa` 创建 `WanfaCategory`、`WanfaRoute` 表并导入稳定初始 ID;`WonderQ-Admin-UI` 已接入分类和路线的查询、新增、编辑、删除及排序操作。 当前实现:`WonderQ-Admin` 通过迁移 `0015_wanfa` 创建 `WanfaCategory`、`WanfaRoute` 表并导入初始数据;`0022_opaque_ids` 将历史语义 ID 转换为稳定 UUID;`WonderQ-Admin-UI` 已接入分类和路线的查询、新增、编辑、删除及排序操作。
所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。
路线接口只维护分类和路线摘要字段。路线详情不写入 `WanfaRoute`,由独立 `DetailRecord` 通过 `docs/detail-api.md` 管理,详情记录的 `key` 等于路线 ID。首页玩法推荐和玩法页路线点击后统一跳转 `/pages/detail/index?routeId={route.id}`;无关联路线时才回退到需求页。 路线接口只维护分类和路线摘要字段。路线详情不写入 `WanfaRoute`,由独立 `DetailRecord` 通过 `docs/detail-api.md` 管理,详情记录的 `key` 等于路线 ID。首页玩法推荐和玩法页路线点击后统一跳转 `/pages/detail/index?routeId={route.id}`;无关联路线时才回退到需求页。
分类和路线的正式 `id` 均为服务端生成的稳定 UUID 字符串。下方本地数据映射中的语义 ID 只用于 MiniAPP fallback 和迁移前数据识别,不作为正式接口响应 ID;不要在序列化时临时随机生成 ID。
## 领域边界 ## 领域边界
玩法管理只维护玩法分类和路线卡片内容: 玩法管理只维护玩法分类和路线卡片内容:
@@ -107,10 +109,10 @@ type WanfaReorderRequest = {
| 字段 | 类型 | 必填 | 约束和用途 | | 字段 | 类型 | 必填 | 约束和用途 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `category.id` | `string` | 响应必填 | 分类稳定标识,例如 `family-route`。创建时由后端生成。 | | `category.id` | `string` | 响应必填 | 分类稳定标识,服务端生成 UUID;创建后在列表、详情、排序和删除请求中保持不变。 |
| `category.label` | `string` | 是 | 左侧分类显示名称,去除首尾空白后不得为空。 | | `category.label` | `string` | 是 | 左侧分类显示名称,去除首尾空白后不得为空。 |
| `category.routes` | `WanfaRoute[]` | 响应必填 | 当前分类下的路线,按展示顺序返回。 | | `category.routes` | `WanfaRoute[]` | 响应必填 | 当前分类下的路线,按展示顺序返回。 |
| `route.id` | `string` | 响应必填 | 路线稳定标识,例如 `family-water`。创建时由后端生成。 | | `route.id` | `string` | 响应必填 | 路线稳定标识,服务端生成 UUID;详情 `key` 与该 ID 一致。 |
| `route.title` | `string` | 是 | 路线卡片标题,去除首尾空白后不得为空。 | | `route.title` | `string` | 是 | 路线卡片标题,去除首尾空白后不得为空。 |
| `route.subtitle` | `string` | 是 | 路线卡片副标题或目的地说明。 | | `route.subtitle` | `string` | 是 | 路线卡片副标题或目的地说明。 |
| `route.image` | `string` | 是 | 可直接用于图片组件的封面 URL。 | | `route.image` | `string` | 是 | 可直接用于图片组件的封面 URL。 |
+2
View File
@@ -6,6 +6,8 @@
所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。
客片案例记录的正式 `id` 为服务端生成的稳定 UUID 字符串;列表、详情和删除使用同一 ID。本地 fallback 的语义 ID 仅用于接口失败时定位模拟内容。
## 领域边界 ## 领域边界
客片案例是首页内容领域的一类展示内容,不属于商品、Product、ProductImage、订单或预订领域。 客片案例是首页内容领域的一类展示内容,不属于商品、Product、ProductImage、订单或预订领域。