docs: 统一业务资源ID为服务端生成的稳定UUID
更新所有业务API文档,明确持久化资源的正式ID必须为服务端生成的稳定UUID,本地调试或接口失败时可使用语义ID作为fallback。新增数据库迁移脚本0022_opaque_ids,用于将历史语义ID转换为稳定UUID,并同步外键关联、详情记录的key字段以及审计日志的实体ID引用。新增该迁移的单元测试用例,验证ID替换与关联数据同步的逻辑正确性。调整MiniAPP前端代码,优化导航工具函数的格式,移除废弃函数并修改首页跳转逻辑,使用接口返回的UUID作为详情跳转参数。
This commit is contained in:
210
WonderQ-Admin/alembic/versions/0022_opaque_ids.py
Normal file
210
WonderQ-Admin/alembic/versions/0022_opaque_ids.py
Normal 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
|
||||
60
WonderQ-Admin/tests/test_opaque_ids_migration.py
Normal file
60
WonderQ-Admin/tests/test_opaque_ids_migration.py
Normal 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,
|
||||
}
|
||||
]
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- 变更接口写入审计日志后再提交事务。
|
||||
- 成功业务结果统一放在 `data`;创建成功为 HTTP/code `201`。
|
||||
- 失败统一返回数字 `code`、用户可读 `msg`、`data: null`,业务错误码放在可选的 `errorCode`。
|
||||
- 所有持久化资源的 `id` 由后端生成稳定 UUID 字符串。Admin UI 必须保存并复用接口返回的 ID,不能根据标题、文案或数组下标自行拼接,也不能假设 ID 是可读 slug。
|
||||
|
||||
## 接口清单
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 成功响应
|
||||
|
||||
### 查询、更新、排序
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。
|
||||
|
||||
Admin 顾问记录的 `id` 为服务端生成的稳定 UUID 字符串;MiniAPP Public 响应按现有契约不返回顾问 ID。不要使用姓名、角色或本地 mock ID 作为正式顾问标识。
|
||||
|
||||
## 领域边界
|
||||
|
||||
管家管理只维护管家顾问卡片资料:
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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,字段值和当前数组顺序保持不变:
|
||||
|
||||
### 体验推荐
|
||||
|
||||
|
||||
@@ -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` 为准。
|
||||
|
||||
## 接口变更流程
|
||||
|
||||
@@ -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` 的本地兜底内容。
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。
|
||||
|
||||
团队共创记录的正式 `id` 为服务端生成的稳定 UUID 字符串;MiniAPP 本地 fallback 可以保留语义 ID,但接口成功后必须以 UUID 作为详情请求参数。
|
||||
|
||||
## 领域边界
|
||||
|
||||
- 团队共创卡片和详情共用一条 `HomeTeamBuilding` 记录。
|
||||
|
||||
@@ -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。 |
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。
|
||||
|
||||
客片案例记录的正式 `id` 为服务端生成的稳定 UUID 字符串;列表、详情和删除使用同一 ID。本地 fallback 的语义 ID 仅用于接口失败时定位模拟内容。
|
||||
|
||||
## 领域边界
|
||||
|
||||
客片案例是首页内容领域的一类展示内容,不属于商品、Product、ProductImage、订单或预订领域。
|
||||
|
||||
Reference in New Issue
Block a user