chore: initial commit — import worldmodel workspace (plans/, research/)
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
# 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<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`](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 或风控拦下。
|
||||
Reference in New Issue
Block a user