# 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 或风控拦下。