From d3a246a873258073303b3666bf9f078f4ce155a2 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Wed, 19 Aug 2026 22:28:13 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E7=BB=9F=E4=B8=80=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E8=B5=84=E6=BA=90ID=E4=B8=BA=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=9A=84=E7=A8=B3=E5=AE=9AUUID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新所有业务API文档,明确持久化资源的正式ID必须为服务端生成的稳定UUID,本地调试或接口失败时可使用语义ID作为fallback。新增数据库迁移脚本0022_opaque_ids,用于将历史语义ID转换为稳定UUID,并同步外键关联、详情记录的key字段以及审计日志的实体ID引用。新增该迁移的单元测试用例,验证ID替换与关联数据同步的逻辑正确性。调整MiniAPP前端代码,优化导航工具函数的格式,移除废弃函数并修改首页跳转逻辑,使用接口返回的UUID作为详情跳转参数。 --- .../alembic/versions/0022_opaque_ids.py | 210 ++++++++++++++++++ .../tests/test_opaque_ids_migration.py | 60 +++++ WonderQ-MiniAPP/src/lib/navigation.ts | 13 +- WonderQ-MiniAPP/src/pages/home/index.vue | 16 +- docs/admin-api-requirements.md | 1 + docs/api-response-contract.md | 8 + docs/concierge-api.md | 2 + docs/detail-api.md | 2 +- docs/home-api.md | 4 +- docs/integration-workflow.md | 9 +- docs/public-api.md | 1 + docs/team-building-api.md | 2 + docs/wanfa-api.md | 8 +- docs/wild-archives-api.md | 2 + 14 files changed, 314 insertions(+), 24 deletions(-) create mode 100644 WonderQ-Admin/alembic/versions/0022_opaque_ids.py create mode 100644 WonderQ-Admin/tests/test_opaque_ids_migration.py diff --git a/WonderQ-Admin/alembic/versions/0022_opaque_ids.py b/WonderQ-Admin/alembic/versions/0022_opaque_ids.py new file mode 100644 index 0000000..82e6def --- /dev/null +++ b/WonderQ-Admin/alembic/versions/0022_opaque_ids.py @@ -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 diff --git a/WonderQ-Admin/tests/test_opaque_ids_migration.py b/WonderQ-Admin/tests/test_opaque_ids_migration.py new file mode 100644 index 0000000..168c8ae --- /dev/null +++ b/WonderQ-Admin/tests/test_opaque_ids_migration.py @@ -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, + } + ] diff --git a/WonderQ-MiniAPP/src/lib/navigation.ts b/WonderQ-MiniAPP/src/lib/navigation.ts index 34bebf9..066a93c 100644 --- a/WonderQ-MiniAPP/src/lib/navigation.ts +++ b/WonderQ-MiniAPP/src/lib/navigation.ts @@ -4,8 +4,13 @@ type QueryValue = string | number | boolean | undefined | null; function withQuery(path: string, query: Record = {}) { const params = Object.entries(query) - .filter(([, value]) => value !== undefined && value !== null && value !== "") - .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) + .filter( + ([, value]) => value !== undefined && value !== null && value !== "", + ) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`, + ) .join("&"); return params ? `${path}?${params}` : path; } @@ -27,10 +32,6 @@ export function goBack(fallback = "/pages/home/index") { goRoot(fallback); } -export function goDemand(destination?: string) { - go("/pages/demand/index", { destination }); -} - export function goWildArchives() { go("/pages/wild-archives/index"); } diff --git a/WonderQ-MiniAPP/src/pages/home/index.vue b/WonderQ-MiniAPP/src/pages/home/index.vue index a91e28f..b0a3eec 100644 --- a/WonderQ-MiniAPP/src/pages/home/index.vue +++ b/WonderQ-MiniAPP/src/pages/home/index.vue @@ -2,10 +2,9 @@ - - + - + @@ -21,13 +20,10 @@ import HomeExperienceList from "./components/HomeExperienceList.vue"; import HomeTeamBuilding from "./components/HomeTeamBuilding.vue"; import HomeWildArchives from "./components/HomeWildArchives.vue"; import HomeVehicleGrid from "./components/HomeVehicleGrid.vue"; -import HomeWanfaRecommendations from "./components/HomeWanfaRecommendations.vue"; import type { HomeExperience } from "./components/homeExperienceData"; import type { HomeTeamBuilding as HomeTeamBuildingItem } from "./components/homeTeamBuildingData"; import type { HomeWildArchive } from "./components/homeWildArchivesData"; -import type { HomeWanfaRecommendation } from "./components/homeWanfaRecommendationData"; import { - goDemand, goTeamBuildingDetail, goWanfaRouteDetail, goWildArchiveDetail, @@ -42,15 +38,11 @@ onShow(() => { void loadHomeContent(); }); -function openHero(title: string) { - goDemand(title); -} - function openExperience(item: HomeExperience) { - goDemand(item.demandKeyword); + goWanfaRouteDetail(item.id); } -function openWanfaRecommendation(item: HomeWanfaRecommendation) { +function openVehicle(item: any) { } diff --git a/docs/admin-api-requirements.md b/docs/admin-api-requirements.md index 5949400..fb809fd 100644 --- a/docs/admin-api-requirements.md +++ b/docs/admin-api-requirements.md @@ -20,6 +20,7 @@ - 变更接口写入审计日志后再提交事务。 - 成功业务结果统一放在 `data`;创建成功为 HTTP/code `201`。 - 失败统一返回数字 `code`、用户可读 `msg`、`data: null`,业务错误码放在可选的 `errorCode`。 +- 所有持久化资源的 `id` 由后端生成稳定 UUID 字符串。Admin UI 必须保存并复用接口返回的 ID,不能根据标题、文案或数组下标自行拼接,也不能假设 ID 是可读 slug。 ## 接口清单 diff --git a/docs/api-response-contract.md b/docs/api-response-contract.md index 99ccc76..3867faa 100644 --- a/docs/api-response-contract.md +++ b/docs/api-response-contract.md @@ -24,6 +24,14 @@ `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。 + ## 成功响应 ### 查询、更新、排序 diff --git a/docs/concierge-api.md b/docs/concierge-api.md index b233635..24226b9 100644 --- a/docs/concierge-api.md +++ b/docs/concierge-api.md @@ -6,6 +6,8 @@ 所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 +Admin 顾问记录的 `id` 为服务端生成的稳定 UUID 字符串;MiniAPP Public 响应按现有契约不返回顾问 ID。不要使用姓名、角色或本地 mock ID 作为正式顾问标识。 + ## 领域边界 管家管理只维护管家顾问卡片资料: diff --git a/docs/detail-api.md b/docs/detail-api.md index 9c738c0..9d4cfd0 100644 --- a/docs/detail-api.md +++ b/docs/detail-api.md @@ -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`。 diff --git a/docs/home-api.md b/docs/home-api.md index 4460f1f..eda596c 100644 --- a/docs/home-api.md +++ b/docs/home-api.md @@ -21,6 +21,8 @@ `demandKeyword` 只是点击卡片后预填需求页的普通字符串,不是商品 ID、路线 ID、订单 ID,也不建立数据库外键。 +首页正式接口返回的所有资源 `id` 都是稳定 UUID 字符串。不要把下方 fallback 文件中的语义 ID 当作服务端 ID,也不要在接口序列化时重新生成 ID;首页卡片跳转详情、排序和删除必须复用接口返回的同一 ID。 + 当前首页通过 `GET /api/public/home` 消费四类内容;三个卡片 mock 数组仍作为接口失败、空响应或字段缺失时的前台 fallback,玩法推荐无本地模拟数据时保持空态。Admin UI 通过本文件列出的 Admin API 维护正式数据。 本文件所有 JSON 示例的业务对象均位于统一响应的 `data` 字段内,完整包裹格式见 [api-response-contract.md](./api-response-contract.md)。 @@ -336,7 +338,7 @@ Admin 响应记录包含 `id`、`categoryId`、`categoryLabel`、`routeCount`、 ## 当前 fallback 与迁移映射 -迁移初始数据时应保留以下稳定 ID、字段值和当前数组顺序: +以下语义 ID 仅用于 MiniAPP 本地 fallback 和迁移前的内容识别;执行 `0022_opaque_ids` 后,正式 API 返回对应记录的稳定 UUID,字段值和当前数组顺序保持不变: ### 体验推荐 diff --git a/docs/integration-workflow.md b/docs/integration-workflow.md index c0c5885..0226239 100644 --- a/docs/integration-workflow.md +++ b/docs/integration-workflow.md @@ -93,13 +93,20 @@ MiniAPP 联调重点: 路线详情使用独立 `DetailRecord`,不修改 `WanfaRoute` 表结构,也不建立商品、订单或预订关联。联调顺序如下: -1. 执行数据库迁移,确认 `0021_detail_records` 已创建详情表并为已有路线生成基础记录;生产环境执行前按迁移规范单独确认。 +1. 执行数据库迁移,确认 `0021_detail_records` 已创建详情表并为已有路线生成基础记录;确认 `0022_opaque_ids` 已将历史语义 ID 转换为稳定 UUID,并同步玩法外键、详情 `key` 和审计引用。生产环境执行前按迁移规范单独确认。 2. 在 Admin UI 进入“玩法”,编辑路线摘要和详情字段,保存时先保存路线,再以路线 ID 作为 `DetailRecord.key` 创建或更新详情。 3. 检查 `GET /api/public/wanfa/categories` 仍只返回路线摘要;检查 `GET /api/public/home` 的玩法推荐携带关联路线摘要。 4. 在 MiniAPP 首页玩法推荐或玩法页点击路线,确认跳转 `/pages/detail/index?routeId={routeId}`,并请求 `GET /api/public/details/{routeId}`。 5. 修改 Admin UI 的详情内容后刷新 MiniAPP,确认标题、正文、费用说明、注意事项和画廊更新;停用或删除详情时确认 Public API 返回 `404`。 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` 为准。 ## 接口变更流程 diff --git a/docs/public-api.md b/docs/public-api.md index 5a784ca..f834d10 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -7,6 +7,7 @@ - API 前缀:`/api/public`。 - 响应使用 JSON;时间使用 ISO 8601 字符串。 - 所有 `/health` 和 `/api/public/**` JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 +- 所有返回的持久化资源 `id` 都是稳定 UUID 字符串,不是标题、分类名或本地 mock 使用的语义 ID;详情、列表和跳转必须复用同一个 ID。 - H5 本地开发通过 `/api` 代理访问后端。 - 内容接口失败时,MiniAPP 使用 `src/content.ts` 的本地兜底内容。 diff --git a/docs/team-building-api.md b/docs/team-building-api.md index c5a5249..1d67bd7 100644 --- a/docs/team-building-api.md +++ b/docs/team-building-api.md @@ -5,6 +5,8 @@ 所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 +团队共创记录的正式 `id` 为服务端生成的稳定 UUID 字符串;MiniAPP 本地 fallback 可以保留语义 ID,但接口成功后必须以 UUID 作为详情请求参数。 + ## 领域边界 - 团队共创卡片和详情共用一条 `HomeTeamBuilding` 记录。 diff --git a/docs/wanfa-api.md b/docs/wanfa-api.md index 1297fed..479275f 100644 --- a/docs/wanfa-api.md +++ b/docs/wanfa-api.md @@ -4,12 +4,14 @@ > > 状态:已实现契约。本文件参考 `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`。 路线接口只维护分类和路线摘要字段。路线详情不写入 `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.routes` | `WanfaRoute[]` | 响应必填 | 当前分类下的路线,按展示顺序返回。 | -| `route.id` | `string` | 响应必填 | 路线稳定标识,例如 `family-water`。创建时由后端生成。 | +| `route.id` | `string` | 响应必填 | 路线稳定标识,服务端生成 UUID;详情 `key` 与该 ID 一致。 | | `route.title` | `string` | 是 | 路线卡片标题,去除首尾空白后不得为空。 | | `route.subtitle` | `string` | 是 | 路线卡片副标题或目的地说明。 | | `route.image` | `string` | 是 | 可直接用于图片组件的封面 URL。 | diff --git a/docs/wild-archives-api.md b/docs/wild-archives-api.md index e019aa5..7cf5714 100644 --- a/docs/wild-archives-api.md +++ b/docs/wild-archives-api.md @@ -6,6 +6,8 @@ 所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 +客片案例记录的正式 `id` 为服务端生成的稳定 UUID 字符串;列表、详情和删除使用同一 ID。本地 fallback 的语义 ID 仅用于接口失败时定位模拟内容。 + ## 领域边界 客片案例是首页内容领域的一类展示内容,不属于商品、Product、ProductImage、订单或预订领域。