Files
worldmodel/plans/CrowdRoom/02_api_contract.md
T
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

524 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "CrowdRoom · API 契约与转码管线(v0.2"
date: 2026-05-20
draft: false
tags: ["CrowdRoom", "众包", "3D 重建", "隐私", "API", "iOS"]
categories: ["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`](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 不能每次复制 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 三条管道总览
```mermaid
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 | 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`](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 URLTTL = 24h,单次签发;过期重新调 E-19
> 4. 并发限制:同 user 同时只允许 1 个 export 任务,重复调用返回 `ACCOUNT_EXPORT_PENDING`
> 5. 对应 [`09_privacy.md`](09_privacy.md) §6.1 C-4GDPR 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 全文搜索 RPCE-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`1020% 原体积) | `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 | 版本未 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`](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-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 错误响应体格式
```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. 给子任务 3iOS/ 4Web)的契约要点
### 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 跑上传协调/防刷/RemixStorage 直传直读 |
| **16 个端点** | 6 PostgREST + 8 Edge Function + 2 Storage 直通 |
| **转码序列图 + 9 步流水** | usdzconvert → gltf-transform → 分层标注 → manifest → 缩略图 → 回写 → 通知;指数退避 30/120/480s3 次后转人工 |
| **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`](03_ios_app_plan.md) 在此契约上实现 iOS 端的扫描、脱敏、上传与 Realtime 监听。
---
**章节版本**v0.1 · 草案
**关键收获**CrowdRoom 的服务端 = 9 表(schema+ 16 端点(contract+ 1 个转码 Worker;客户端任何"奇技淫巧"(比如绕过 Edge Function 直 INSERT)都会被 RLS 或风控拦下。