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

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

View File

@@ -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

View File

@@ -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,
}
]

View File

@@ -4,8 +4,13 @@ type QueryValue = string | number | boolean | undefined | null;
function withQuery(path: string, query: Record<string, QueryValue> = {}) {
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");
}

View File

@@ -2,10 +2,9 @@
<view class="wq-page">
<view class="wq-phone">
<scroll-view scroll-y class="h-screen no-scrollbar" :show-scrollbar="false">
<HomeHeroCarousel :slides="appState.data.heroSlides" @select="openHero" />
<HomeWanfaRecommendations :items="homeContentState.playRecommendations" @select="openWanfaRecommendation" />
<HomeHeroCarousel :slides="appState.data.heroSlides" />
<HomeExperienceList :items="homeContentState.experiences" @select="openExperience" />
<HomeVehicleGrid :vehicle-options="vehicleOptions" @select="goDemand('贵州')" />
<HomeVehicleGrid :vehicle-options="vehicleOptions" @select="openVehicle" />
<HomeTeamBuilding :items="homeContentState.teamBuildings" @select="openTeamBuilding" />
<HomeWildArchives :items="homeContentState.wildArchives" @select="openWildArchive" @more="openWildArchives" />
</scroll-view>
@@ -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) {
}

View File

@@ -20,6 +20,7 @@
- 变更接口写入审计日志后再提交事务。
- 成功业务结果统一放在 `data`;创建成功为 HTTP/code `201`
- 失败统一返回数字 `code`、用户可读 `msg``data: null`,业务错误码放在可选的 `errorCode`
- 所有持久化资源的 `id` 由后端生成稳定 UUID 字符串。Admin UI 必须保存并复用接口返回的 ID不能根据标题、文案或数组下标自行拼接也不能假设 ID 是可读 slug。
## 接口清单

View File

@@ -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。
## 成功响应
### 查询、更新、排序

View File

@@ -6,6 +6,8 @@
所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data``null`
Admin 顾问记录的 `id` 为服务端生成的稳定 UUID 字符串MiniAPP Public 响应按现有契约不返回顾问 ID。不要使用姓名、角色或本地 mock ID 作为正式顾问标识。
## 领域边界
管家管理只维护管家顾问卡片资料:

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`

View File

@@ -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字段值和当前数组顺序保持不变
### 体验推荐

View File

@@ -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` 为准。
## 接口变更流程

View File

@@ -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` 的本地兜底内容。

View File

@@ -5,6 +5,8 @@
所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data``null`
团队共创记录的正式 `id` 为服务端生成的稳定 UUID 字符串MiniAPP 本地 fallback 可以保留语义 ID但接口成功后必须以 UUID 作为详情请求参数。
## 领域边界
- 团队共创卡片和详情共用一条 `HomeTeamBuilding` 记录。

View File

@@ -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。 |

View File

@@ -6,6 +6,8 @@
所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data``null`
客片案例记录的正式 `id` 为服务端生成的稳定 UUID 字符串;列表、详情和删除使用同一 ID。本地 fallback 的语义 ID 仅用于接口失败时定位模拟内容。
## 领域边界
客片案例是首页内容领域的一类展示内容不属于商品、Product、ProductImage、订单或预订领域。