Files
gaojie 1ea74b46da
Sync to site1 / sync (push) Has been cancelled
chore: update CrowdRoom categories from worldmodel to CrowdRoom
2026-05-21 02:20:22 +08:00

30 KiB
Raw Permalink Blame History

title, date, draft, tags, categories
title date draft tags categories
CrowdRoom · API 契约与转码管线(v0.2) 2026-05-20 false
CrowdRoom
众包
3D 重建
隐私
API
iOS
CrowdRoom

CrowdRoom · API 契约与转码管线(v0.2)

版本v0.22026-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 不能每次复制 110 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 端点表

路径前缀:

  • PostgRESThttps://{project}.supabase.co/rest/v1/
  • Edge Functionshttps://{project}.supabase.co/functions/v1/
  • Storagehttps://{project}.supabase.co/storage/v1/

鉴权要求 列:anon = 任何人;user = 必须携带用户 JWTowner = 必须为资源 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 binarypresigned 200 OK (presigned) STORAGE_FORBIDDEN
E-03 PUT /storage/v1/object/private/rooms/{room_id}/v{n}/source.roomplan.json Storage binarypresigned 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-2E-16 DELETE /rest/v1/rooms 在 v0.2 起仅供「无任何公开 Remix 子代」的房间使用;存在公开 Remix 时必须改走 E-17,由 Edge Function 先做快照转移再 cascade 删,否则 RLS / DB 层会因 room_versions → remixeson delete restrictROOM_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 §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 §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 §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=$1cascade 影子表 + 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 URLTTL = 24h,单次签发;过期重新调 E-19
  4. 并发限制:同 user 同时只允许 1 个 export 任务,重复调用返回 ACCOUNT_EXPORT_PENDING
  5. 对应 09_privacy.md §6.1 C-4GDPR 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 全文搜索 RPCE-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 usdzconvertApple+ gltf-transform source.usdz intermediate.gltf USDZ_DECODE_FAILED, GLTF_ENCODE_FAILED
T-3 几何压缩 gltf-transform meshopt / Draco intermediate.gltf canonical.glb1020% 原体积) 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.jspuppeteer + 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_FAILEDTHUMBNAIL_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_INVALIDschema 校验失败)。

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=trueasset_id 必须存在于 assetskind='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 状态码 + 业务码并存(业务码在 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 版本未 readymanifest 还没产出 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-18T+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,拒绝二次回调

错误总数:264xx,含 v0.2 / G-3 新增 5 条)+ 55xx+ 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-completeredactions[]贴图本身也要在端侧高斯模糊(不依赖服务端二次脱敏) 违反 → 公开作品里出现人脸 → 法务风险
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 跑上传协调/防刷/RemixStorage 直传直读
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 档 free10 房间/月)vs creator50 房间/月);匿名只读
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 或风控拦下。