--- title: "CrowdRoom · API 契约与转码管线(v0.2)" date: 2026-05-20 draft: false tags: ["CrowdRoom", "众包", "3D 重建", "隐私", "API", "iOS"] categories: ["CrowdRoom"] --- # 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`](09_privacy.md) §4 P-4、§5 P-W-7 与 [`10_governance.md`](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`](00_overview.md) §4 架构图与 [`01_data_schema.md`](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 三条管道总览 ```mermaid graph LR CLIENT[iOS / Web] PGRST[Supabase PostgREST
auto-gen from tables] EDGE[Edge Functions
Deno runtime] STG[Supabase Storage
direct upload/download] WORKER[Transcode Worker
Fly.io container] DB[(Postgres)] CLIENT -- "GET rooms, comments, assets
SELECT 走 RLS" --> PGRST PGRST --> DB CLIENT -- "POST upload-init / transcode-done
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
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`](09_privacy.md) §4 P-4 与 [`10_governance.md`](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 新增) > > 1. 入参校验:`auth.uid() = rooms.owner_id`,否则 `RLS_DENIED` > 2. 若 `mode='soft'`:`UPDATE rooms SET deleted_at=now(), visibility='private' WHERE id=$1`,立即从 Feed 与搜索消失(详见 [`01_data_schema.md`](01_data_schema.md) §3.11) > 3. 若 `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`](01_data_schema.md) §3.6 v0.2 新增字段) > - 复制完成后 `DELETE FROM public.rooms WHERE id=$1` → cascade 子表 + 删除原 Storage 路径 > - 任一步失败 → 事务回滚 + Storage 复制产物异步 GC + 返回 `PARENT_SNAPSHOT_TRANSFER_FAILED` > 4. 返回 `snapshots_transferred = N`(让 UI 提示「N 个公开 Remix 已自动保留」) > > #### E-18 `/account-delete` 逻辑骨架(v0.2 / G-2 新增) > > 三阶段流程对齐 [`09_privacy.md`](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_cron` 02:00 调用) | 对该 user 的每个 `rooms` 依次走 E-17 `hard` 流程 → 然后 `DELETE FROM users WHERE id=$1`(cascade 影子表 + auth.users) | ❌ | > > #### E-19 `/account-export` 逻辑骨架(v0.2 / G-2 新增) > > 1. 入参 `?include=...` 控制导出范围(默认全选);输出 ZIP 包含:`profile.json / rooms.json / remixes.json / comments.json / likes.json / redactions.json / source/` 目录(私有 bucket 中的 `.usdz / .roomplan.json`) > 2. 异步执行:首次调用返回 `{ status: 'pending', export_id }`;客户端轮询同端点带 `?export_id=...` 获取 `{ status: 'ready', download_url, expires_at }` > 3. `download_url` 是 Storage presigned URL,TTL = 24h,单次签发;过期重新调 E-19 > 4. 并发限制:同 user 同时只允许 1 个 export 任务,重复调用返回 `ACCOUNT_EXPORT_PENDING` > 5. 对应 [`09_privacy.md`](09_privacy.md) §6.1 C-4(GDPR Art. 20 数据可携权) ### 2.2 PostgREST 列表请求示例(E-06) ```http 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 + 标签过滤: ```sql 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 序列图 ```mermaid 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 示例 ```json { "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 入参 ```json { "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 出参与存储 ```json { "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`](E-15) 实时返回,超额时上游端点(`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` 返回结构: ```json { "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 状态码 + 业务码并存(业务码在 body `error.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`](04_web_app_plan.md) §1.1 R-12 与 [`10_governance.md`](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-After` header 提示;遇到 `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 错误响应体格式 ```json { "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`](03_ios_app_plan.md) 在此契约上实现 iOS 端的扫描、脱敏、上传与 Realtime 监听。 --- **章节版本**:v0.1 · 草案 **关键收获**:CrowdRoom 的服务端 = 9 表(schema)+ 16 端点(contract)+ 1 个转码 Worker;客户端任何"奇技淫巧"(比如绕过 Edge Function 直 INSERT)都会被 RLS 或风控拦下。