30 KiB
title, date, draft, tags, categories
| title | date | draft | tags | categories | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| CrowdRoom · API 契约与转码管线(v0.2) | 2026-05-20 | false |
|
|
CrowdRoom · API 契约与转码管线(v0.2)
版本:v0.2(2026-05-19) v0.2 修订:回写 G-2(追加 3 个 Edge Function 端点 E-17/E-18/E-19,对应账号注销三阶段、数据导出、父硬删时 Remix 快照转移)+ G-3(追加 5 条业务错误码
EMBED_RATE_LIMITED/EMBED_FORBIDDEN/ACCOUNT_DELETION_IN_PROGRESS/ACCOUNT_EXPORT_PENDING/PARENT_SNAPSHOT_TRANSFER_FAILED)。源决策见09_privacy.md§4 P-4、§5 P-W-7 与10_governance.md§4 P-W-3 / P-W-6。 编号说明:任务原文建议新端点编号 E-16/17/18,但原 v0.1 §2.1 中 E-16 已占用为DELETE /rest/v1/rooms;为不破坏既有引用,新端点顺延为 E-17 / E-18 / E-19,同时把原 E-16 标注为「v0.2 起被 E-17 软封装」。
本章承接
00_overview.md§4 架构图与01_data_schema.md的 9 张表,定义客户端 ↔ Supabase 的所有 API、Edge Function 业务逻辑、转码 Worker 的 sequence,以及 Remix 覆盖层、配额、错误码。API 路径 / 字段 / 错误码用英文;解释用简体中文。
1. API 分层决策(先拍板)
CrowdRoom 把 API 分到三条管道,避免"什么都往 Edge Function 塞"或"什么都让客户端直查 PostgREST"两个极端。
| # | 关键决策 | 拍板 | 理由 |
|---|---|---|---|
| D-A1 | 列表/详情/搜索是否走 PostgREST | 走 PostgREST(不写 Edge Function) | RLS 已经把可见性收得很死,PostgREST 自动生成的 OpenAPI 完全够 React Query 直接消费;Edge Function 反而要重复维护一份过滤逻辑 |
| D-A2 | 上传 .usdz 怎么走 | 客户端 → Edge Function 拿 presigned URL → 直传 Storage → 回调 Edge Function | 30 MB+ 文件不该走 Edge Function 中转(4MB body 限制 + 冷启动 + 计费爆炸);presigned URL 把流量留在 Storage |
| D-A3 | 点赞防刷 | 走 Edge Function like-toggle(不让客户端直 INSERT) |
客户端能拿到自己的 JWT,可以一秒发几百次;Edge Function 做 60 s 速率限制 + 幂等键,比在 Postgres 写 advisory lock 简单 |
| D-A4 | Remix 是深拷贝几何还是覆盖层 | 覆盖层(reference + overlay),只存 diff | 同一房间衍生 N 个 remix 不能每次复制 1–10 MB 的 .glb;Web 端在浏览器里实时合成覆盖层;几何来源永远是父版本的 canonical.glb(见 §4) |
| D-A5 | 转码 Worker 在哪 | 独立容器(Fly.io / Railway),由 Edge Function 通过 HTTP POST 入队 | Supabase Edge Functions 跑不动 USD CLI 与 gltf-transform(依赖 native binaries、内存 1GB+);分离后 Worker 可以独立扩缩容 |
1.1 三条管道总览
graph LR
CLIENT[iOS / Web]
PGRST[Supabase PostgREST<br/>auto-gen from tables]
EDGE[Edge Functions<br/>Deno runtime]
STG[Supabase Storage<br/>direct upload/download]
WORKER[Transcode Worker<br/>Fly.io container]
DB[(Postgres)]
CLIENT -- "GET rooms, comments, assets<br/>SELECT 走 RLS" --> PGRST
PGRST --> DB
CLIENT -- "POST upload-init / transcode-done<br/>POST like-toggle / remix-create" --> EDGE
EDGE -- "service_role 写 status / counters" --> DB
EDGE -- "presigned URL 签发 / Realtime 通知" --> CLIENT
EDGE -- "HTTP POST job" --> WORKER
CLIENT -- "PUT source.usdz<br/>GET canonical.glb" --> STG
WORKER -- "GET source.* / PUT canonical.*" --> STG
WORKER -- "POST transcode-done" --> EDGE
2. 核心 API 端点表
路径前缀:
- PostgREST:
https://{project}.supabase.co/rest/v1/- Edge Functions:
https://{project}.supabase.co/functions/v1/- Storage:
https://{project}.supabase.co/storage/v1/鉴权要求 列:
anon= 任何人;user= 必须携带用户 JWT;owner= 必须为资源 owner(由 RLS 强制);service= 仅 Worker 用 service_role key。速率限制 默认走 Cloudflare WAF + Edge Function 内 KV 计数;列中只标"敏感端点"特殊值。
2.1 端点全表(共 16 条)
| # | METHOD | 路径 | 通道 | 入参 | 出参 | 鉴权 | 速率 | 失败码 |
|---|---|---|---|---|---|---|---|---|
| E-01 | POST | /functions/v1/upload-init |
Edge | { room_id?, version_no?, title, tags[], visibility, bytes_source } |
{ room_id, version_id, version_no, presigned_usdz, presigned_json, expires_at } |
user | 10/min/user | QUOTA_EXCEEDED, FILE_TOO_LARGE, INVALID_TAGS |
| E-02 | PUT | /storage/v1/object/private/rooms/{room_id}/v{n}/source.usdz |
Storage | binary(presigned) | 200 OK |
(presigned) | — | STORAGE_FORBIDDEN |
| E-03 | PUT | /storage/v1/object/private/rooms/{room_id}/v{n}/source.roomplan.json |
Storage | binary(presigned) | 200 OK |
(presigned) | — | STORAGE_FORBIDDEN |
| E-04 | POST | /functions/v1/upload-complete |
Edge | { version_id, redactions[] } |
{ version_id, status: 'queued' } |
user(owner) | 10/min/user | VERSION_NOT_FOUND, SOURCE_MISSING, REDACTION_INVALID |
| E-05 | POST | /functions/v1/transcode-done |
Edge | { version_id, status, manifest_path, glb_path, thumbnail_path, summary, error? } |
{ ok: true } |
service | — | BAD_SIGNATURE, LAYER_MANIFEST_INVALID, ROOM_TRANSCODE_FAILED |
| E-06 | GET | /rest/v1/rooms?visibility=eq.public&order=created_at.desc&limit=20 |
PostgREST | query string | Room[] |
anon | — | RLS_DENIED |
| E-07 | GET | /rest/v1/rooms?id=eq.{room_id}&select=*,current_version:room_versions(*) |
PostgREST | path/query | Room |
anon/owner | — | ROOM_NOT_FOUND, RLS_DENIED |
| E-08 | GET | /rest/v1/rpc/search_rooms?q={text}&tag={tag} |
PostgREST | q, tag, limit |
Room[] |
anon | 60/min/IP | SEARCH_QUERY_TOO_SHORT |
| E-09 | GET | /storage/v1/object/public/rooms/{room_id}/v{n}/layer_manifest.json |
Storage(CDN) | — | manifest JSON | anon | — | MANIFEST_NOT_FOUND |
| E-10 | POST | /functions/v1/view-state |
Edge | { room_id, version_id, layer_toggles, camera, overlay_id? } |
{ share_token, share_url } |
anon/user | 30/min | VIEW_STATE_INVALID |
| E-11 | POST | /functions/v1/remix-create |
Edge | { parent_version_id, title, overlay } |
{ remix_id, overlay_path } |
user | 20/min/user | REMIX_PARENT_DELETED, REMIX_PARENT_NOT_READY, OVERLAY_INVALID, ASSET_NOT_FOUND |
| E-12 | POST | /functions/v1/like-toggle |
Edge | { room_id } |
{ liked: bool, like_count: int } |
user | 5/sec/user(防刷) | RATE_LIMITED, ROOM_NOT_FOUND |
| E-13 | POST | /rest/v1/comments |
PostgREST | { room_id, body, reply_to? } |
Comment |
user | 30/min/user | COMMENT_TOO_LONG, RLS_DENIED |
| E-14 | POST | /functions/v1/report |
Edge | { target_type, target_id, reason, detail? } |
{ report_id } |
user | 10/hour/user | REPORT_DUPLICATE, INVALID_TARGET |
| E-15 | GET | /functions/v1/quota |
Edge | — | Quota(见 §6) |
user | — | — |
| E-16 | DELETE | /rest/v1/rooms?id=eq.{room_id} |
PostgREST | path | 204 |
owner | — | RLS_DENIED, ROOM_HAS_REMIXES |
| E-17 | POST | /functions/v1/room-delete-with-snapshot |
Edge | { room_id, mode: 'soft'|'hard' } |
{ room_id, mode, deleted_at, snapshots_transferred: int } |
user(owner) | 5/min/user | RLS_DENIED, ROOM_NOT_FOUND, PARENT_SNAPSHOT_TRANSFER_FAILED, STORAGE_PUT_FAILED |
| E-18 | POST | /functions/v1/account-delete |
Edge | { confirm_password, stage?: 't0'|'t7_revoke'|'t30_force' } |
{ user_id, stage, deleted_at, hard_delete_eta } |
user | 1/hour/user | UNAUTHENTICATED, ACCOUNT_DELETION_IN_PROGRESS, PARENT_SNAPSHOT_TRANSFER_FAILED |
| E-19 | GET | /functions/v1/account-export |
Edge | ?include=rooms,remixes,comments,likes,redactions,profile(默认全选) |
{ export_id, status: 'pending'|'ready', download_url?, expires_at? } |
user | 2/day/user | UNAUTHENTICATED, ACCOUNT_EXPORT_PENDING, QUOTA_EXCEEDED |
共 19 个端点(v0.2):11 个 Edge Function、6 个 PostgREST、2 个 Storage 直传/直读。新增 3 条均为 v0.2 / G-2 回写,源决策
09_privacy.md§4 P-4 与10_governance.md§4 P-W-3。
🔄 v0.2 — 回写自 G-2:E-16
DELETE /rest/v1/rooms在 v0.2 起仅供「无任何公开 Remix 子代」的房间使用;存在公开 Remix 时必须改走 E-17,由 Edge Function 先做快照转移再 cascade 删,否则 RLS / DB 层会因room_versions → remixes的on delete restrict抛ROOM_HAS_REMIXES。客户端发现 E-16 返回ROOM_HAS_REMIXES时应自动 fallback 到 E-17。E-17
/room-delete-with-snapshot逻辑骨架(v0.2 / G-2 新增)
- 入参校验:
auth.uid() = rooms.owner_id,否则RLS_DENIED- 若
mode='soft':UPDATE rooms SET deleted_at=now(), visibility='private' WHERE id=$1,立即从 Feed 与搜索消失(详见01_data_schema.md§3.11)- 若
mode='hard':
- service_role 事务内遍历所有
remixes子代(is_public=true AND deleted_at IS NULL)- 对每个被引用的
room_versions,把canonical_glb_path/manifest_path复制到public/remix-fallbacks/{parent_room_id}/v{n}/- 更新
remixes.parent_snapshot_path字段(01_data_schema.md§3.6 v0.2 新增字段)- 复制完成后
DELETE FROM public.rooms WHERE id=$1→ cascade 子表 + 删除原 Storage 路径- 任一步失败 → 事务回滚 + Storage 复制产物异步 GC + 返回
PARENT_SNAPSHOT_TRANSFER_FAILED- 返回
snapshots_transferred = N(让 UI 提示「N 个公开 Remix 已自动保留」)E-18
/account-delete逻辑骨架(v0.2 / G-2 新增)三阶段流程对齐
09_privacy.md§4 P-4:
阶段 stage参数行为 用户可挽回 T+0 't0'(默认;调用方提交confirm_password)软删 users / rooms / remixes / comments / likes(设deleted_at=now()),rooms.visibility='private',JWT 即刻失效;返回hard_delete_eta = now() + 30d✅ 7 天内通过 DPO 邮箱可走 stage='t7_revoke'撤销T+7 — 业务 API 拒绝任何撤销请求 → ACCOUNT_DELETION_IN_PROGRESS❌ T+30 't30_force'(仅 service_role 在pg_cron02:00 调用)对该 user 的每个 rooms依次走 E-17hard流程 → 然后DELETE FROM users WHERE id=$1(cascade 影子表 + auth.users)❌ E-19
/account-export逻辑骨架(v0.2 / G-2 新增)
- 入参
?include=...控制导出范围(默认全选);输出 ZIP 包含:profile.json / rooms.json / remixes.json / comments.json / likes.json / redactions.json / source/目录(私有 bucket 中的.usdz / .roomplan.json)- 异步执行:首次调用返回
{ status: 'pending', export_id };客户端轮询同端点带?export_id=...获取{ status: 'ready', download_url, expires_at }download_url是 Storage presigned URL,TTL = 24h,单次签发;过期重新调 E-19- 并发限制:同 user 同时只允许 1 个 export 任务,重复调用返回
ACCOUNT_EXPORT_PENDING- 对应
09_privacy.md§6.1 C-4(GDPR Art. 20 数据可携权)
2.2 PostgREST 列表请求示例(E-06)
GET /rest/v1/rooms?visibility=eq.public
&order=created_at.desc
&limit=20&offset=0
&select=id,title,tags,cover_color,like_count,
owner:users(handle,display_name,avatar_url),
current_version:room_versions!current_version_id(thumbnail_path,status)
Authorization: Bearer {anon_key}
apikey: {anon_key}
返回经 RLS 过滤后的列表;客户端拿 thumbnail_path 拼 CDN URL 即可。
2.3 全文搜索 RPC(E-08)
search_rooms 是 Postgres function,封装 tsvector + 标签过滤:
create function public.search_rooms(q text, tag text default null, lim int default 20)
returns setof public.rooms
language sql stable
as $$
select r.* from public.rooms r
where r.visibility = 'public'
and (q is null or r.search_tsv @@ plainto_tsquery('simple', q))
and (tag is null or tag = any(r.tags))
order by ts_rank(r.search_tsv, plainto_tsquery('simple', coalesce(q, ''))) desc,
r.created_at desc
limit lim;
$$;
3. 转码管线(Transcode Worker)
3.1 序列图
sequenceDiagram
autonumber
participant iOS as iOS App
participant Edge as Edge Function
participant Stg as Supabase Storage
participant DB as Postgres
participant W as Transcode Worker
participant RT as Realtime Channel
participant Web as Web Client
iOS->>Edge: POST upload-init (title, tags, bytes)
Edge->>DB: insert rooms / room_versions (status=uploading)
Edge->>Stg: sign presigned URLs (usdz + json)
Edge-->>iOS: { version_id, presigned_usdz, presigned_json }
iOS->>Stg: PUT source.usdz (direct)
iOS->>Stg: PUT source.roomplan.json (direct)
iOS->>Edge: POST upload-complete (version_id, redactions[])
Edge->>DB: update room_versions.status = queued; insert redactions
Edge->>W: POST /enqueue { version_id, source_paths }
W->>Stg: GET source.usdz + source.roomplan.json
W->>W: usdz -> glb (usdzconvert / usd-from-gltf)
W->>W: parse roomplan.json -> 4 层节点标注
W->>W: 生成 layer_manifest.json
W->>W: 渲染 thumbnail.webp + preview.mp4
W->>Stg: PUT canonical.glb / layer_manifest.json / thumbnail.webp
W->>Edge: POST transcode-done (status=ready, paths, summary)
Edge->>DB: update room_versions.status=ready; rooms.current_version_id
Edge->>RT: broadcast 'room:{room_id}' { event:'ready', version_id }
RT-->>iOS: WebSocket push (用户收到"扫描已上线")
RT-->>Web: WebSocket push (列表页插入新卡片)
3.2 转码步骤明细
| 步骤 | 工具 | 输入 | 输出 | 失败码 |
|---|---|---|---|---|
| T-1 拉取 | Supabase SDK | private bucket | 本地 /tmp/src/ |
STORAGE_FETCH_FAILED |
| T-2 USDZ→glTF | usdzconvert(Apple)+ gltf-transform |
source.usdz |
intermediate.gltf |
USDZ_DECODE_FAILED, GLTF_ENCODE_FAILED |
| T-3 几何压缩 | gltf-transform meshopt / Draco |
intermediate.gltf |
canonical.glb(10–20% 原体积) |
MESH_COMPRESSION_FAILED |
| T-4 分层标注 | 自研 Python(基于 RoomPlan JSON 的 walls/floors/objects semantic anchors) |
source.roomplan.json + canonical.glb 节点 |
节点 ID 标注(wall_*, floor_*, furn_*) |
LAYER_TAG_FAILED |
| T-5 manifest 合成 | 自研 Python | 上一步标注 | layer_manifest.json(通过 JSON Schema 校验) |
LAYER_MANIFEST_INVALID |
| T-6 缩略图 | headless three.js(puppeteer + webgl)或 gltf-transform render |
canonical.glb |
thumbnail.webp (640×360, 1280×720) |
THUMBNAIL_FAILED |
| T-7 预览动画(可选) | 同上 + ffmpeg | 5 个相机轨迹关键帧 | preview.mp4 |
失败可容忍,不阻塞 ready |
| T-8 回写 | Supabase SDK | 产物 | public/rooms/{id}/v{n}/ |
STORAGE_PUT_FAILED |
| T-9 通知 | HTTP POST transcode-done |
summary | DB + Realtime | TRANSCODE_DONE_REJECTED |
3.3 失败重试策略
attempt 1 fail -> wait 30s -> attempt 2
attempt 2 fail -> wait 120s -> attempt 3
attempt 3 fail -> status = 'failed', transcode_error 记录, 进人工队列
- 指数退避:30 s → 120 s → 480 s,全部失败后入
manual_review队列(人工排查) - 幂等:同一
version_id重试时,Worker 必须先清掉public/rooms/.../v{n}/.tmp/暂存目录 - 不可重试错误:
LAYER_MANIFEST_INVALID(即 manifest 通不过 JSON Schema)应在 attempt 1 立即标failed——多试无用 - 可重试错误:
STORAGE_FETCH_FAILED、THUMBNAIL_FAILED、网络/超时类 - 超时:单次最长 5 分钟(大于 50 MB 的 USDZ 拒收,在 E-01 就该拦下)
3.4 上传协调状态机
uploading --upload-complete--> queued
| |
| (TTL 30min 未 complete) v
+--> failed transcoding
|
attempt<3 失败 | ready
v ^
failed |
| |
manual review -+
|
archived
uploading 超过 30 分钟未收到 upload-complete 由 Postgres pg_cron 定时清理为 failed,避免僵尸记录。
4. Remix 数据模型决策
4.1 拍板:引用 + 覆盖层(reference + overlay),不深拷贝几何
为什么:CrowdRoom 的内容飞轮预期 1 个房间被 N 次 remix(设计师试色场景)。若每次 remix 都深拷贝
canonical.glb,存储成本 O(N × room_size);用覆盖层只存 diff 是 O(N × small_diff)。代价是 Web 端要在浏览器里实时合成——但 Three.js 切换节点可见性 / 替换材质本来就是 60fps 操作,无运行时压力。对应错误码:
REMIX_PARENT_DELETED(父版本不可硬删,必须先 tombstone)、REMIX_PARENT_NOT_READY(父 status ≠ ready 时不允许 remix)、OVERLAY_INVALID(schema 校验失败)。
4.2 remix_overlay.json Schema 示例
{
"schema_version": "1.0.0",
"parent_version_id": "c7e0...",
"parent_glb_uri": "rooms/8f1c.../v1/canonical.glb",
"parent_manifest_uri": "rooms/8f1c.../v1/layer_manifest.json",
"ops": [
{
"op": "replace_furniture",
"target_item_id": "bed_001",
"asset_id": "a91c2...",
"asset_glb_uri": "assets/furniture/modern_bed_oak.glb",
"transform": {
"translate": [0, 0, 0],
"rotate_quat": [1, 0, 0, 0],
"scale": [1, 1, 1]
},
"snap_to_anchor": true
},
{
"op": "replace_material",
"target_slot_id": "mat_floor_wood",
"asset_id": "a8e92...",
"pbr_override": {
"base_color_tex": "assets/materials/oak_dark/base.webp",
"normal_tex": "assets/materials/oak_dark/normal.webp",
"roughness": 0.55,
"metallic": 0.0
}
},
{
"op": "hide_layer",
"layer_kind": "furniture",
"target_item_ids": ["tv_001"]
},
{
"op": "set_wall_color",
"target_mesh_id": "wall_2",
"base_color": [0.85, 0.78, 0.92, 1.0]
}
],
"camera_state": {
"position": [2.4, 1.6, 3.0],
"look_at": [0.0, 0.8, 0.0],
"fov_deg": 55
}
}
4.3 支持的 op 类型
| op | 作用 | 校验 |
|---|---|---|
replace_furniture |
隐藏父 item 的 mesh_node_ids[],在 anchor_point 处插入 asset_glb_uri |
target_item_id 必须在父 manifest furniture.items[] 中且 replaceable=true;asset_id 必须存在于 assets 表 kind='furniture' |
replace_material |
把 manifest 中某个 slot 的 PBR 整体替换为新 asset | target_slot_id 必须在父 manifest materials.slots[] 中且 replaceable=true |
hide_layer |
整层或某些 item 隐藏 | layer_kind ∈ {walls, floor, furniture, materials};如有 target_item_ids 则只隐藏部分 |
set_wall_color |
单墙改色,不需要换贴图 | 仅 walls 层 mesh 可用 |
add_decoration |
在某个 anchor 旁挂额外资产(如盆栽) | 新增,不引用 target_item_id;位置走 world OBB |
fork 语义:remix-create 端点本质是 INSERT INTO remixes(parent_version_id, overlay);不复制几何、不复制 manifest——Web 端在加载 remix 时拉父 canonical.glb + 父 layer_manifest.json + 自己的 overlay,三者在浏览器里合成最终视图。
5. 可分享 viewState(图层切换状态)
view-state(E-10)把 Web 端用户当前的"开关哪些层 + 相机视角 + 应用了哪个 overlay"打包成一个短 token,可粘到任何 IM/邮件里。
5.1 入参
{
"room_id": "8f1c...",
"version_id": "c7e0...",
"layer_toggles": {
"walls": true,
"floor": true,
"furniture": false,
"materials": true
},
"camera": {
"position": [2.4, 1.6, 3.0],
"look_at": [0.0, 0.8, 0.0],
"fov_deg": 55
},
"overlay_id": null
}
5.2 出参与存储
{
"share_token": "vs_aB7xC9...",
"share_url": "https://crowdroom.app/r/8f1c.../?vs=vs_aB7xC9..."
}
实现:Edge Function 把 viewState body 序列化后 gzip + base64url,得到 share_token;不入 Postgres(无需持久化),TTL 由 token 自带过期戳(默认 90 天)。Web 端解码即可恢复状态。这样无需新增表,分享链接也可离线生成统计。
6. 用户配额表
配额由
/functions/v1/quota实时返回,超额时上游端点(upload-init等)抛QUOTA_EXCEEDED。
| 维度 | 匿名用户 | 注册用户(free) | 创作者(registered + verified email) |
|---|---|---|---|
| 总存储 | — | 500 MB | 2 GB |
单文件 source.usdz 大小 |
— | 30 MB | 50 MB |
单文件 canonical.glb 大小(产出) |
— | 8 MB | 15 MB |
| 月上传房间数 | — | 10 | 50 |
| 月转码分钟数 | — | 60 min | 240 min |
| 单房间版本数 | — | 5 | 20 |
| 月 Remix 数 | — | 30 | 200 |
| 评论/天 | — | 50 | 200 |
| 点赞 QPS | — | 5 /秒 | 5 /秒 |
| 公开作品数上限 | — | 30 | 200 |
| 仅可浏览(read-only) | ✅ | ✅ | ✅ |
quota 返回结构:
{
"tier": "free",
"storage": { "used_bytes": 134217728, "limit_bytes": 524288000 },
"uploads_this_month": { "used": 4, "limit": 10 },
"transcode_minutes_this_month": { "used": 12.4, "limit": 60 },
"remixes_this_month": { "used": 6, "limit": 30 },
"reset_at": "2026-06-01T00:00:00Z"
}
实现:Edge Function 用 Postgres materialized view 每小时刷新 user_quota_usage,配合实时累加(上传完成时 +1)。
7. 错误码总表
命名规则:
{DOMAIN}_{REASON},全部大写下划线;HTTP 状态码 + 业务码并存(业务码在 bodyerror.code)。
7.1 4xx 客户端错误
| 业务码 | HTTP | 含义 | 触发端点 |
|---|---|---|---|
UNAUTHENTICATED |
401 | 缺/过期 JWT | 所有 user 端点 |
RLS_DENIED |
403 | RLS 拒绝(如查私有房间) | PostgREST |
STORAGE_FORBIDDEN |
403 | presigned URL 失效或不匹配 | E-02/E-03 |
ROOM_NOT_FOUND |
404 | 房间不存在/对该用户不可见 | E-07/E-12 |
MANIFEST_NOT_FOUND |
404 | 版本未 ready,manifest 还没产出 | E-09 |
VERSION_NOT_FOUND |
404 | version_id 不存在 | E-04 |
INVALID_TAGS |
400 | 标签 > 8 个或含非法字符 | E-01 |
FILE_TOO_LARGE |
413 | 超出配额单文件上限 | E-01 |
COMMENT_TOO_LONG |
400 | body > 1000 字符 | E-13 |
SEARCH_QUERY_TOO_SHORT |
400 | q 长度 < 2 | E-08 |
VIEW_STATE_INVALID |
400 | layer_toggles 缺键 / camera 字段缺失 | E-10 |
REDACTION_INVALID |
400 | redactions[] region 字段不通过 schema | E-04 |
OVERLAY_INVALID |
400 | overlay ops 不通过 schema 或引用不存在的 item/slot | E-11 |
ASSET_NOT_FOUND |
404 | overlay 引用了不存在的 asset_id | E-11 |
REMIX_PARENT_NOT_READY |
409 | 父版本 status ≠ ready | E-11 |
REMIX_PARENT_DELETED |
410 | 父房间/版本已 tombstone | E-11/E-09 |
ROOM_HAS_REMIXES |
409 | 删除房间被 remix 引用,需先 tombstone | E-16 |
RATE_LIMITED |
429 | 超过端点速率限制 | E-08/E-12/E-14 |
QUOTA_EXCEEDED |
429 | 超月度/存储配额 | E-01/E-11 |
REPORT_DUPLICATE |
409 | 同一 target 24h 内已被同人举报 | E-14 |
INVALID_TARGET |
400 | target_type ∉ {room,remix,comment,user} | E-14 |
EMBED_RATE_LIMITED |
429 | iframe 嵌入超过 Referer/IP 限流(v0.2 / G-3) | /embed/r/{id} 路由(详见 04_web_app_plan.md §1.1 R-12 与 10_governance.md §4 P-W-6) |
EMBED_FORBIDDEN |
403 | iframe Referer 命中黑名单(NSFW/违规域名)或房间已关嵌入(v0.2 / G-3) | 同上 |
ACCOUNT_DELETION_IN_PROGRESS |
409 | 账号已进入 T+7 不可撤阶段(v0.2 / G-3) | E-18 |
ACCOUNT_EXPORT_PENDING |
409 | 同一用户已有正在进行的数据导出任务(v0.2 / G-3) | E-19 |
PARENT_SNAPSHOT_TRANSFER_FAILED |
500 | 父房间硬删时 Remix 几何快照转移失败(事务已回滚)(v0.2 / G-3) | E-17 / E-18(T+30 阶段) |
🔄 v0.2 — 回写自 G-3:上表末尾 5 条业务码为 v0.2 新增。其中
EMBED_RATE_LIMITED/EMBED_FORBIDDEN由 Vercel Edge Middleware(或 Upstash Rate Limit)在/embed/r/{id}路由前置拦截;PARENT_SNAPSHOT_TRANSFER_FAILED由 E-17 Edge Function 在事务回滚后返回;ACCOUNT_*两条由 E-18 / E-19 状态机抛出。客户端遇到EMBED_RATE_LIMITED时应展示Retry-Afterheader 提示;遇到PARENT_SNAPSHOT_TRANSFER_FAILED时房间不会被删除(事务回滚保证),UI 应提示「Remix 快照转移失败,房间仍存在;请稍后重试或联系支持」。
7.2 5xx 服务端错误
| 业务码 | HTTP | 含义 | 处理 |
|---|---|---|---|
STORAGE_FETCH_FAILED |
502 | Worker 拉源文件失败 | 重试 |
STORAGE_PUT_FAILED |
502 | Worker 回写失败 | 重试 |
EDGE_TIMEOUT |
504 | Edge Function 超 30s | 重试一次,否则告警 |
WORKER_UNAVAILABLE |
503 | Worker 队列爆满 | 客户端 60s 后重试 |
INTERNAL_ERROR |
500 | 未分类异常 | Sentry 告警 |
7.3 业务转码错误(5xx 但语义明确)
| 业务码 | HTTP | 含义 | 是否可重试 |
|---|---|---|---|
USDZ_DECODE_FAILED |
422 | usdzconvert 解析失败 | ❌ 不可(源文件损坏) |
GLTF_ENCODE_FAILED |
500 | gltf-transform 编码失败 | ✅ 可 |
MESH_COMPRESSION_FAILED |
500 | Draco/Meshopt 失败 | ✅ 可 |
LAYER_TAG_FAILED |
500 | RoomPlan JSON anchor 与 glb 节点对不齐 | ✅ 可一次 |
LAYER_MANIFEST_INVALID |
422 | manifest 不通过 JSON Schema | ❌ 不可(Worker bug) |
THUMBNAIL_FAILED |
500 | headless 渲染失败 | ✅ 可,失败不阻塞 ready |
ROOM_TRANSCODE_FAILED |
500 | 终态:3 次重试后仍失败 | ❌ 转人工 |
BAD_SIGNATURE |
401 | transcode-done 没带正确 service_role 签名 |
❌ |
TRANSCODE_DONE_REJECTED |
409 | DB 状态已是 ready/archived,拒绝二次回调 | ❌ |
错误总数:26(4xx,含 v0.2 / G-3 新增 5 条)+ 5(5xx)+ 9(转码业务码)= 40 条(v0.1 为 35 条)。
7.4 错误响应体格式
{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "Monthly upload limit reached (10/10).",
"details": {
"quota_kind": "uploads_this_month",
"reset_at": "2026-06-01T00:00:00Z"
},
"request_id": "req_01J..."
}
}
8. 给子任务 3(iOS)/ 4(Web)的契约要点
8.1 iOS 端必须保证(X)
| # | 契约 | 后果 |
|---|---|---|
| iOS-X1 | 上传前必须在端侧跑 Vision 人脸检测并把命中区域以 kind=face 写入 upload-complete 的 redactions[],贴图本身也要在端侧高斯模糊(不依赖服务端二次脱敏) |
违反 → 公开作品里出现人脸 → 法务风险 |
| iOS-X2 | 上传走 upload-init → 直传 Storage → upload-complete 三步,禁止把 .usdz 字节流塞进 Edge Function body |
违反 → 413 / Edge Function 计费爆炸 |
| iOS-X3 | RoomPlan JSON 原样上传,不要在端侧做坐标系变换(Worker 统一做 +Z up → +Y up) | 违反 → manifest 节点对不齐 → LAYER_TAG_FAILED |
| iOS-X4 | 监听 Realtime channel room:{room_id} 等待 ready 事件,不要轮询 room_versions.status |
违反 → 浪费配额 |
| iOS-X5 | quota 端点每次进入"扫描页"前查一次;预估失败时本地拦截,不要上传完了才看到 QUOTA_EXCEEDED |
UX 差 |
8.2 Web 端必须遵守(Y)
| # | 契约 | 后果 |
|---|---|---|
| Web-Y1 | 渲染入口必须先拉 layer_manifest.json,按 4 层固定 ID 切换可见性;不要自己解析 .glb 节点树推断结构 |
违反 → Worker 改了节点命名就坏 |
| Web-Y2 | Remix 模式下必须实时合成(父 glb + 父 manifest + overlay),不要请求服务端预合成 | 违反 → 服务端没有这个端点,会 404 |
| Web-Y3 | 点赞/取消赞必须走 like-toggle(幂等 + 防刷),不要直接 INSERT/DELETE likes 表 |
违反 → RLS 不挡,但会被风控封号 |
| Web-Y4 | 分享链接 ?vs=... 解码后必须对未知字段宽容(前向兼容) |
违反 → schema 升级导致旧链接失效 |
| Web-Y5 | 缩略图与 .glb 一律走 CDN URL(/storage/v1/object/public/...),不要发起带 JWT 的请求 |
违反 → 命中率 0% + Storage 计费暴涨 |
| Web-Y6 | 评论提交后乐观更新,但失败时回滚——错误码 RLS_DENIED 表示用户已被拉黑该房间 |
违反 → 评论"消失" |
9. 本章小结
| 关键产出 | 一句话 |
|---|---|
| API 分层 3 通道 | PostgREST 跑 CRUD + 列表搜索;Edge Function 跑上传协调/防刷/Remix;Storage 直传直读 |
| 16 个端点 | 6 PostgREST + 8 Edge Function + 2 Storage 直通 |
| 转码序列图 + 9 步流水 | usdzconvert → gltf-transform → 分层标注 → manifest → 缩略图 → 回写 → 通知;指数退避 30/120/480s,3 次后转人工 |
| Remix 用覆盖层 | remix_overlay.json 5 种 op,几何永远引用父;父不可硬删,必须 tombstone |
| 配额 2 档 | free(10 房间/月)vs creator(50 房间/月);匿名只读 |
| 35 条错误码 | 4xx / 5xx / 转码业务码三类,统一 error.code 包装 |
读完本章你应能:
- ✅ 直接对照表格在 Supabase Dashboard 创建 8 个 Edge Functions
- ✅ 给 Worker 团队一份"输入/输出/失败码"清单
- ✅ 给 iOS / Web 团队各自一份 5–6 条必守契约
下一章 03_ios_app_plan.md 在此契约上实现 iOS 端的扫描、脱敏、上传与 Realtime 监听。
章节版本:v0.1 · 草案 关键收获:CrowdRoom 的服务端 = 9 表(schema)+ 16 端点(contract)+ 1 个转码 Worker;客户端任何"奇技淫巧"(比如绕过 Edge Function 直 INSERT)都会被 RLS 或风控拦下。