chore: initial commit — import worldmodel workspace (plans/, research/)

This commit is contained in:
gaojie
2026-05-20 21:43:57 +08:00
commit bec8a9a4a3
98 changed files with 44128 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
# CrowdRoom · 总览(v0.1
> **一句话定义**CrowdRoom 是「**RoomPlan 版的 Sketchfab + Pinterest**」——人人用 iPhone 扫一个房间,传到云端就有可在浏览器里 360° 把玩、分层切换、换家具换材质、Remix 再创作的 3D 房间社区。
本章是 CrowdRoom 的总览,与既有 [`plans/PRISM/00_overview.md`](../PRISM/00_overview.md) 对齐:PRISM 是「机器人空间记忆操作系统」,CrowdRoom 是它的**消费级前台与数据入口**——把"专业机器人技术栈"和"普通用户的手机"用一个社区粘合起来。
---
## 1. 产品定位
| 维度 | 描述 |
|------|------|
| 一句话 | "RoomPlan 版的 Sketchfab + Pinterest":扫房 → 上传 → 共享 → Remix |
| 内容形态 | 一个房间 = 一份 RoomPlan 源文件(`.usdz` + JSON+ 服务端转码出的 `.glb` + 4 层可切换图层 |
| 核心动作 | **Scan → Upload → View → Toggle Layers → Remix → Share** |
| 区别于 Sketchfab | 不是任意 3D 模型库,而是**结构化的真实房间**(带语义:墙/地/家具/材质) |
| 区别于 Polycam 社区 | Polycam 偏个人云盘,CrowdRoom 偏**公共内容流 + Remix 文化** |
| 区别于 ArcGIS | 不做专业 GIS 查询,**只做"分层显示/隐藏/替换"这种消费级图层操作** |
CrowdRoom 不和 PRISM 抢机器人场景,它解决的是「**怎么让 RoomPlan 数据从一个人的相册变成一个生态**」的问题。
---
## 2. 目标用户画像
| 画像 | 标签 | 典型行为 | 核心诉求 |
|------|------|----------|----------|
| **A · 扫房爱好者**Scanner | 极客、博主、Polycam 老用户、租房博主 | 周末扫家、扫民宿、扫展览,上传炫技 | 上传顺、出图快、获赞获关注 |
| **B · 灵感党**(Browser) | 室内设计学生、装修业主、ins/小红书图源党 | 不扫,只逛;收藏好看的房间作灵感板 | 浏览流畅、能搜风格、能收藏分组 |
| **C · Remixer**(创作者) | 设计爱好者、3D 玩家、UGC 创作者 | 拿别人扫的房间,换墙纸/换沙发/隐藏家具发"我的版本" | 操作低门槛、能版本对比、能 fork |
三类用户构成**采集 → 消费 → 再创作**的内容飞轮,对应 [`plans/iphone/iphone_simplified_plan.md`](../iphone/iphone_simplified_plan.md) 里"个人验证用 RoomPlan"的轻量场景,但把生产者从 1 人扩到 N 人。
---
## 3. 核心用户故事(MVP 范围内 8 条)
| # | As a | I want to | So that |
|---|------|-----------|---------|
| US-1 | 扫房爱好者 | 在 iOS App 里调用 RoomPlan 扫完房间一键上传 | 不用导出 USDZ 再手动传 |
| US-2 | 扫房爱好者 | 上传时给房间打标签(户型 / 风格 / 城市)并选择公开/私有 | 控制我作品的可见性与隐私 |
| US-3 | 灵感党 | 在浏览器里 360° 旋转、缩放、走进一个房间 | 不装 App 就能看 |
| US-4 | 灵感党 | 用复选框开关 **墙 / 地板 / 家具 / 材质** 四个图层 | 像 ArcGIS 那样按需查看结构或装饰 |
| US-5 | Remixer | 把家具图层里的某件家具替换为公共资产库里的另一件 | 不重新建模就能做"装修方案" |
| US-6 | Remixer | 把墙面/地面材质换成公共材质库里的另一种 | 快速试色 / 试材质 |
| US-7 | 任何用户 | 给一个房间点赞、评论、收藏到画板 | 形成社区互动 |
| US-8 | 扫房爱好者 | 上传前由 App 自动模糊照片中的人脸/身份证/品牌 logo | 默认隐私安全,不用我手工检查 |
> 故意**不包含**的 user stories:实时多人协同编辑、复杂 BIM/CAD 出图、专业 GIS 空间查询、跨房间拼接成楼层——见 §7 非目标。
---
## 4. 总体架构图
```mermaid
graph TD
subgraph CLIENT["客户端层"]
IOS["iOS App<br/>(RoomPlan SDK 采集 + 上传客户端)<br/>Swift / SwiftUI"]
WEB["Web 前端<br/>(浏览/分层/Remix)<br/>React + R3F + Three.js"]
end
subgraph BAAS["Supabase BaaS 层"]
AUTH["Auth<br/>邮箱/Apple/Google 登录<br/>JWT 下发"]
DB["Postgres<br/>rooms / layers / remixes /<br/>comments / likes 表"]
STG["Storage<br/>原始 .usdz + JSON<br/>+ 转码后 .glb"]
EDGE["Edge Functions<br/>上传回调 / 隐私脱敏触发 /<br/>排行榜聚合"]
end
subgraph WORKER["渲染与转码层"]
TRANS["Transcode Worker<br/>(USDZ → glTF/.glb +<br/>Draco/Meshopt 压缩)<br/>容器化 Node/Python"]
ASSET["Asset Library<br/>公共家具 .glb +<br/>PBR 材质贴图"]
end
subgraph CDN["分发层"]
EDGECDN["CDN<br/>(Cloudflare R2 / Bunny)<br/>直发 .glb 与缩略图"]
end
IOS -- "1. POST 上传 USDZ+JSON" --> STG
IOS -- "登录" --> AUTH
STG -- "2. 触发" --> EDGE
EDGE -- "3. 入队转码任务" --> TRANS
TRANS -- "4. 写回 .glb + layer manifest" --> STG
TRANS -- "5. 更新 status" --> DB
STG -- "公开资源" --> EDGECDN
WEB -- "查列表/详情" --> DB
WEB -- "登录" --> AUTH
WEB -- "拉 .glb / 贴图" --> EDGECDN
WEB -- "查公共素材" --> ASSET
ASSET --> EDGECDN
```
**关键说明**
- iOS App 是**唯一采集入口**RoomPlan 仅 iOS);Web 端只消费、不采集
- BaaSSupabase= Auth + Postgres + Storage + Edge Functions 一站式,避免自建后端
- Transcode Worker 与 Asset Library 是「服务器渲染/转码」的承载者,对应用户原话里的"服务器渲染"
- CDN 直发 `.glb`,Web 端实际渲染发生在**浏览器** GPU(Three.js),服务端不做实时渲染——这是消费级路线的关键性价比决策
---
## 5. 技术栈选型表
| 层 | 推荐 | 备选 | 一行理由 |
|----|------|------|---------|
| 移动端语言 | **Swift + SwiftUI + RoomPlan** | Flutter + 原生桥接 | RoomPlan 是纯 iOS API,原生 Swift 最薄 |
| Web 框架 | **React + Vite + TypeScript** | Next.js / SvelteKit | React 生态最厚,与 R3F 无缝 |
| 3D 渲染库 | **Three.js + React-Three-Fiber + drei** | Babylon.js / PlayCanvas | R3F 让"图层切换/换家具"用 React 组件思维直接表达 |
| BaaS | **Supabase** | Firebase / Appwrite | 开源、Postgres 底座、便于后续平滑迁移到自建 |
| 对象存储 | **Supabase Storage**(小流量)→ **Cloudflare R2**(量大后) | AWS S3 / Backblaze B2 | R2 零出口费,国内外访问都还行 |
| CDN | **Cloudflare** | Bunny.net / 阿里云 CDN | 与 R2 同栈、免费额度大 |
| 转码 Worker | **Node + `gltf-transform` + USD CLI**,跑在 **Fly.io / Railway 容器** | AWS Lambda(冷启动慢,pass | gltf-transform 做 Draco/Meshopt 压缩成熟;USD CLI 解 `.usdz` |
| 公共资产库 | **Google `<model-viewer>` Asset Pack + 自建 PBR 库** | Sketchfab API | 起步用免费 CC0 素材,避开版权 |
| CI/CD | **GitHub Actions**iOS 走 fastlane → TestFlightWeb 走 Vercel | GitLab CI | 学习成本低、与 Supabase 集成方便 |
| 监控 | **Sentry + Supabase Logs** | Datadog | 个人/早期项目够用 |
> 该表只列**MVP 默认选项**;具体表结构、API 端点、转码流水线细节由后续子任务(`01_data_schema.md` / `02_api_contract.md`)展开。
---
## 6. 与 PRISM 的关系:复用与边界
### 6.1 一句话定位
> CrowdRoom = **PRISM 的"消费级前台 + 数据入口"**。
> PRISM 解决"机器人怎么记住一个房间"CrowdRoom 解决"普通用户怎么把房间贡献出来、怎么消费别人的房间"。
> 两者共享同一份"RoomPlan → 结构化空间"的数据约定,但**生命周期、SLA、安全模型完全不同**。
### 6.2 复用点(CrowdRoom 直接受益于 PRISM 的既有设计)
| # | 复用项 | 来源 | 用法 |
|---|--------|------|------|
| R1 | **RoomPlan 数据格式约定**USDZ + JSON 双文件,墙/门窗/家具/尺寸字段) | [`plans/iphone/data_format_specification.md`](../iphone/data_format_specification.md) §2-§3 | CrowdRoom iOS App 直接采用同一份 JSON Schema,避免双标 |
| R2 | **RoomPlan 精度边界与扫描最佳实践** | [`plans/iphone/roomplan_accuracy_and_cad_export.md`](../iphone/roomplan_accuracy_and_cad_export.md) §1, §4.1 | 用于 App 内的扫描引导提示("慢速移动 0.3 m/s""清理杂物")和精度免责声明 |
| R3 | **导出转换链 USDZ ↔ glTF/DXF** | [`plans/iphone/roomplan_accuracy_and_cad_export.md`](../iphone/roomplan_accuracy_and_cad_export.md) §3 | Transcode Worker 复用其 Python/Swift 思路,CrowdRoom 只取 USDZ → glTF 一条 |
| R4 | **L3/L4 节点 + 属性的语义抽象**(房间是 L3、家具是 L4、`category/attributes/material` | [`plans/PRISM/03_data_schema.md`](../PRISM/03_data_schema.md) §3.2 | CrowdRoom 的"分层 = L1/L2/L3/L4 的简化映射":墙/地→L2 度量,家具→L4 语义节点;4 层图层是 PRISM 四层的**降维投影** |
| R5 | **简易方案的快速验证路径**(一人一机一周跑通) | [`plans/iphone/iphone_simplified_plan.md`](../iphone/iphone_simplified_plan.md) §五 | 直接借作 CrowdRoom MVP 的扫描端工时基线 |
### 6.3 暂不引入的 PRISM 特性(边界)
| 不引入 | 理由 | 何时再考虑 |
|--------|------|-----------|
| **Pipeline B 重定位** ([`05_pipeline_B_relocalization.md`](../PRISM/05_pipeline_B_relocalization.md)) | CrowdRoom 没有"机器人上线"的概念,无需 CLIP+ICP 握手 | 若未来做 AR Quicklook 多次访问对齐才需要 |
| **Pipeline C 在线感知** ([`06_pipeline_C_online_perception.md`](../PRISM/06_pipeline_C_online_perception.md)) | ZED 2i 不在消费端硬件清单里 | 永久不引入(消费级路线决策) |
| **Pipeline D 巩固 / Delta 差异记忆** ([`07_pipeline_D_consolidation.md`](../PRISM/07_pipeline_D_consolidation.md)) | 用户每次扫描视为**独立作品**而非同一空间的更新 | 若做"扫描日记/同一房间历史对比"功能再启用 |
| **KeyframeEvidence、Anchor、Delta 字段** ([`plans/PRISM/03_data_schema.md`](../PRISM/03_data_schema.md) §3.2.1) | 这些是为机器人翻案与巩固设计的,与社区浏览无关 | 永久不引入 |
| **Neo4j / Cypher 查询** | 消费级查询用 Postgres 全文索引足够 | 若引入"空间 SQL"P2)再讨论 |
| **PostGIS / 专业空间查询、LOD、楼层拼接** | 用户已明确划入 P2/未来 | 当单房间日活上 10 万级、用户主动要"按城市/按户型查询"才上 |
> **原则**CrowdRoom 是 PRISM 的**只写入口 + 只读消费**;它产出的数据可被 PRISM 后续 import 作为先验地图源("普通用户贡献先验"是诱人的长期愿景,但**不在 MVP 范围内**)。
---
## 7. MVP 范围与非目标
### 7.1 MVP 8 周内能交付(围绕 §3 的 8 条 user stories
| 模块 | 交付物 |
|------|--------|
| iOS AppTestFlight | RoomPlan 采集 + USDZ/JSON 导出 + 上传 + 标签 + 隐私脱敏(人脸模糊) |
| Web 前端 | 列表页 / 详情页 / R3F 渲染器 / 4 层图层切换 / 公共素材替换 / 评论点赞 |
| Supabase 后端 | Auth、`rooms / layers / remixes / comments / likes / assets` 7 张核心表、Storage 桶、上传回调 Edge Function |
| Transcode Worker | USDZ → `.glb`Draco 压缩) + 生成 `layer_manifest.json`4 层引用) |
| 公共资产库 | ≥ 30 件家具 `.glb` + ≥ 20 种 PBR 材质(CC0 来源) |
| 内容审核 | 上传时静态规则 + 人工 review 队列(暂不做 AI 审核) |
### 7.2 明确不做(MVP 外)
- ❌ Android / Web 端采集(RoomPlan 仅 iOS**且不引入 Android ARCore 替代**——避免数据双标)
- ❌ 实时多人协同编辑(Remix 走 fork 模型,不走 OT/CRDT
- ❌ 空间 SQL / 任意自定义图层 / PostGIS 查询(P2
- ❌ LOD、楼层拼接、跨房间空间索引(P2)
- ❌ 商品化(链接到电商)、付费墙、订阅
- ❌ 机器人接入 / PRISM Pipeline B-D 任何一项
- ❌ 自训练 3D 基础模型(家具识别一律用 RoomPlan 内置语义 + 公共资产匹配)
---
## 8. 风险与开放问题(≤ 5 条)
| # | 风险 / 开放问题 | 当前判断 | 待后续子任务回答 |
|---|----------------|---------|-----------------|
| RK-1 | **RoomPlan 仅 iOS 且需 LiDAR**iPhone 12 Pro+ | 接受为前提,等同于早期 Polycam 的市场范围;不做 Android 替代 | 是否在 Web 端也开放"上传第三方 USDZ"作为 PC 用户入口? |
| RK-2 | **`.usdz` 在 Web 端兼容性差**Three.js 无原生 USDZ loader | 服务端强制转 `.glb`Web 端**只见 glTF** | 转码失败率、`.usdz` 中嵌入纹理/动画的边界由 [`02_transcode_pipeline.md`](02_transcode_pipeline.md)(待建)回答 |
| RK-3 | **UGC 内容审核**:色情、违法建筑、他人住宅未授权扫描 | MVP 走"上传时声明 + 举报下架 + 人工 review";不上 AI 审核 | 法律边界(中国大陆 vs 海外)、举报响应 SLA 由 `10_governance.md`(待建)回答 |
| RK-4 | **隐私脱敏**:纹理里可能包含人脸、身份证、品牌 logo、镜子里的人 | 默认开启端侧 Vision 人脸检测 + 高斯模糊;镜面区域参考 PRISM `no_update_zone` 思想做警告 | 脱敏算法细节、用户能否关闭、是否服务端二次扫描由 `01_data_schema.md` + `09_privacy.md`(待建)回答 |
| RK-5 | **存储与带宽成本**:单个房间 `.usdz` 550 MB`.glb` 压后 1–10 MB;千用户日活即可烧光免费额度 | MVP 用 Supabase 免费额度 + Cloudflare R2 零出口费;设单用户上传配额 | 配额数值、冷热分层策略、缩略图分级由 `02_transcode_pipeline.md` 回答 |
---
## 9. 下一章预告
| 子任务 | 待产出 | 关注问题 |
|--------|--------|---------|
| `01_data_schema.md` | Supabase 7 张表 DDL、`layer_manifest.json` Schema、与 PRISM L1-L4 的字段映射 | RK-4 隐私字段、§6 R4 复用怎么落 |
| `02_api_contract.md` | REST/PostgREST + Edge Function 端点表 | 上传协议、转码回调、Remix fork API |
| `03_ios_app_plan.md` | iOS App 模块拆解 + 隐私脱敏实现 | US-1, US-2, US-8 |
| `04_web_app_plan.md` | Web 前端组件树 + R3F 图层渲染策略 | US-3~US-7 |
---
**章节版本**v0.1 · 草案
**关键收获**CrowdRoom 是 PRISM 的消费级前台;走 Supabase + Three.js + iOS-RoomPlan 的三件套路线;MVP 8 周覆盖 8 条核心 user stories;专业 GIS 与 PRISM Pipeline B/C/D 明确划在 MVP 之外。
+981
View File
@@ -0,0 +1,981 @@
# CrowdRoom · 数据模型与 Storage 规范(v0.2
> **版本**v0.22026-05-19
> **v0.2 修订**:回写 G-1`remixes.parent_snapshot_path`)、G-9(所有用户数据表 `deleted_at` 软删字段 + 软/硬删分层 RLS)、G-10`rooms.location_label` 显式标注);本次仅做「追加 / 字段插入 / 文案润色」,未改任何既有字段语义。源决策见 [`09_privacy.md`](09_privacy.md) §4 P-3 / P-4 与 [`10_governance.md`](10_governance.md) §4 P-W-3。
> 本章承接 [`00_overview.md`](00_overview.md) §4 架构图与 §5 技术栈,落地 Supabase Postgres 的 DDL、RLS、Storage 目录、`layer_manifest.json` Schema,以及与 [`plans/PRISM/03_data_schema.md`](../PRISM/03_data_schema.md) L1L4 的映射。
>
> **DDL / JSON Schema / 字段名一律英文**;解释文字用简体中文。
---
## 1. 关键决策(先拍板,再展开)
| # | 决策 | 拍板结论 | 一句话理由 |
|---|------|----------|------------|
| D1 | 7 表 vs 8 表(是否拆 `room_versions` | **拆为 8 表**,新增 [`room_versions`](#22-room_versions) | 转码是异步且会失败的、Remix 必须锁定父版本、重传不应破坏旧链接——三者都强烈需要一个独立的版本实体;与 PRISM `snapshots/` 思路一致([`plans/PRISM/03_data_schema.md`](../PRISM/03_data_schema.md) §3.4 |
| D2 | 隐私脱敏元数据放 `room_versions` 还是独立 `redactions` 表 | **独立 `redactions` 表**(一对多) | 同一版本可有多个脱敏框(人脸 N 个、镜面 M 个、用户手标 K 个),用嵌入 JSONB 会把审计查询变成全表扫;独立表可索引 `kind``applied_at` |
| D3 | 4 层 ID 是否固定 | **固定为 `walls / floor / furniture / materials`**,写死在 enum | 减少前端切换器的字符串拼接错误;任何"未知层"在 Worker 阶段就拒收,避免脏数据进入 Web |
| D4 | 材质层的存储形态 | **逻辑层**`materials``layer_manifest.json` 里以 `slots[]` 出现,不在 Postgres `layers` 表中独立成行 | 材质本质是其它三层 mesh 的 PBR 槽位映射,独立成 SQL 行会导致大量 JOIN;用 manifest 内嵌可以一次拉取完整图层视图 |
| D5 | `rooms` 的全文搜索 | **`tsvector` + GIN 索引**,由触发器从 `title / description / tags` 自动合成 | Supabase 原生支持,零额外依赖;如未来上 Algolia/Meili 再加 outbox 即可 |
---
## 2. 数据模型总览(erDiagram
```mermaid
erDiagram
users ||--o{ rooms : owns
users ||--o{ remixes : creates
users ||--o{ comments : writes
users ||--o{ likes : gives
rooms ||--o{ room_versions : has
rooms ||--o{ comments : receives
rooms ||--o{ likes : receives
rooms ||--o{ remixes : forked_into
room_versions ||--o{ layers : contains
room_versions ||--o{ redactions : applies
remixes }o--|| room_versions : forks_from
assets ||--o{ remixes : referenced_by
users {
uuid id PK
text handle
text display_name
text avatar_url
timestamptz created_at
}
rooms {
uuid id PK
uuid owner_id FK
text title
text description
text[] tags
text visibility
uuid current_version_id FK
tsvector search_tsv
timestamptz created_at
}
room_versions {
uuid id PK
uuid room_id FK
int version_no
text status
text source_usdz_path
text source_json_path
text canonical_glb_path
text manifest_path
text thumbnail_path
jsonb roomplan_summary
timestamptz created_at
}
layers {
uuid id PK
uuid version_id FK
text layer_kind
jsonb manifest_node
}
redactions {
uuid id PK
uuid version_id FK
text kind
jsonb region
text source
timestamptz applied_at
}
remixes {
uuid id PK
uuid parent_version_id FK
uuid author_id FK
jsonb overlay
text title
timestamptz created_at
}
comments {
uuid id PK
uuid room_id FK
uuid author_id FK
text body
timestamptz created_at
}
likes {
uuid id PK
uuid room_id FK
uuid user_id FK
timestamptz created_at
}
assets {
uuid id PK
text kind
text glb_path
jsonb pbr
text license
text semantic_class
}
```
> 共 **9 个实体盒**,其中 `users` 是 Supabase `auth.users` 的影子表(`public.users`),其余 8 张是 CrowdRoom 业务表。
> 🔄 **v0.2 — 回写自 G-10**:本节 ER 图中 `rooms` 实体的 `location_city` 字段在 v0.2 起以**业务别名** `location_label TEXT NULL`(城市级 5 km 精度标签)对外表述,与 [`09_privacy.md`](09_privacy.md) §3.3 / §4 P-3 决策对齐。DDL 字段名保持 `location_city` 不动以避免破坏既有迁移脚本;新增的别名仅用于跨文档术语统一(见 §3.2 字段块脚注)。
>
> 🔄 **v0.2 — 回写自 G-9**:本节 ER 图所有用户数据实体(`users / rooms / room_versions / remixes / comments / likes`)在 v0.2 起新增 `deleted_at TIMESTAMPTZ NULL` 软删字段(图中未画出以避免拥挤,详见 §3 各表 DDL 与 §3.11 软/硬删分层 RLS 策略)。
>
> 🔄 **v0.2 — 回写自 G-1**:本节 ER 图 `remixes` 实体在 v0.2 起新增 `parent_snapshot_path TEXT NULL` 字段,用于 [`10_governance.md`](10_governance.md) §4 P-W-3 「父房间硬删时把最后一个 ready 公开版本的几何快照转移到 Remix」的落地。
---
## 3. PostgreSQL DDL(在 Supabase SQL Editor 中可直接运行)
### 3.0 前置 enum 与扩展
```sql
create extension if not exists "pgcrypto"; -- gen_random_uuid()
create extension if not exists "pg_trgm"; -- trigram 模糊搜索
create type room_visibility as enum ('public', 'unlisted', 'private');
create type version_status as enum ('uploading', 'queued', 'transcoding',
'ready', 'failed', 'archived');
create type layer_kind as enum ('walls', 'floor', 'furniture', 'materials');
create type redaction_kind as enum ('face', 'mirror', 'logo', 'user_marked', 'plate');
create type asset_kind as enum ('furniture', 'material');
```
### 3.1 `users`auth.users 的公开影子表)
```sql
create table public.users (
id uuid primary key references auth.users(id) on delete cascade,
handle text unique not null check (handle ~ '^[a-zA-Z0-9_]{3,24}$'),
display_name text not null,
avatar_url text,
bio text,
created_at timestamptz not null default now(),
-- v0.2 / G-9:账号注销 T+0 软删 / T+7 不可撤 / T+30 硬删(详见 §3.11 与 09_privacy §4 P-4
deleted_at timestamptz null
);
create index users_handle_trgm on public.users using gin (handle gin_trgm_ops);
alter table public.users enable row level security;
create policy users_select_all on public.users for select using (true);
create policy users_update_self on public.users for update
using (auth.uid() = id) with check (auth.uid() = id);
create policy users_insert_self on public.users for insert
with check (auth.uid() = id);
-- DELETE 不开放:由 auth.users 级联
```
### 3.2 `rooms`
```sql
create table public.rooms (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references public.users(id) on delete cascade,
title text not null check (char_length(title) between 1 and 120),
description text check (char_length(description) <= 4000),
tags text[] not null default '{}',
visibility room_visibility not null default 'public',
current_version_id uuid, -- 延迟外键,避免与 room_versions 形成创建死锁
cover_color text, -- 16 进制主色,用作占位
location_city text, -- 用户自填,不做 GPS
-- v0.2 / G-10location_label 是 location_city 的业务别名,城市级 5 km 精度(对应 P-3)
-- 不新增列;location_city 字段语义=「城市级标签」,跨文档(09_privacy / 04_web_app_plan
-- 一律以 location_label 称呼。如未来需要彻底重命名,需走 v0.3 迁移脚本。
like_count int not null default 0,
remix_count int not null default 0,
comment_count int not null default 0,
search_tsv tsvector,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
-- v0.2 / G-9:软删字段(业务 API 走软删;service_role cron T+30 后物理 cascade 删)
deleted_at timestamptz null
);
create index rooms_owner on public.rooms (owner_id);
create index rooms_visibility on public.rooms (visibility) where visibility = 'public';
create index rooms_tags_gin on public.rooms using gin (tags);
create index rooms_search_gin on public.rooms using gin (search_tsv);
create index rooms_created_desc on public.rooms (created_at desc);
-- tsvector 自动同步
create function rooms_tsv_trigger() returns trigger as $$
begin
new.search_tsv :=
setweight(to_tsvector('simple', coalesce(new.title, '')), 'A') ||
setweight(to_tsvector('simple', array_to_string(new.tags, ' ')), 'B') ||
setweight(to_tsvector('simple', coalesce(new.description, '')), 'C');
new.updated_at := now();
return new;
end $$ language plpgsql;
create trigger rooms_tsv_update
before insert or update of title, description, tags
on public.rooms for each row execute function rooms_tsv_trigger();
alter table public.rooms enable row level security;
-- 公开/不公开列表
create policy rooms_select_public on public.rooms for select
using (visibility in ('public', 'unlisted') or owner_id = auth.uid());
create policy rooms_insert_self on public.rooms for insert
with check (owner_id = auth.uid());
create policy rooms_update_owner on public.rooms for update
using (owner_id = auth.uid()) with check (owner_id = auth.uid());
create policy rooms_delete_owner on public.rooms for delete
using (owner_id = auth.uid());
```
### 3.3 `room_versions`
```sql
create table public.room_versions (
id uuid primary key default gen_random_uuid(),
room_id uuid not null references public.rooms(id) on delete cascade,
version_no int not null,
status version_status not null default 'uploading',
-- Storage object 路径(不含 bucket 名)
source_usdz_path text,
source_json_path text,
canonical_glb_path text,
manifest_path text,
thumbnail_path text,
preview_mp4_path text,
-- 转码摘要:从 RoomPlan JSON 抽取的统计(房间面积、家具数等)
roomplan_summary jsonb,
bytes_source bigint,
bytes_canonical bigint,
transcode_error text,
transcode_attempts int not null default 0,
created_at timestamptz not null default now(),
ready_at timestamptz,
-- v0.2 / G-9:版本级软删(避免删除转码失败/旧版本时立即丢失审计)
deleted_at timestamptz null,
unique (room_id, version_no)
);
create index room_versions_room on public.room_versions (room_id);
create index room_versions_status on public.room_versions (status);
-- 补外键:rooms.current_version_id → room_versions.id
alter table public.rooms
add constraint rooms_current_version_fk
foreign key (current_version_id) references public.room_versions(id)
on delete set null deferrable initially deferred;
alter table public.room_versions enable row level security;
create policy versions_select_via_room on public.room_versions for select
using (
exists (select 1 from public.rooms r
where r.id = room_id
and (r.visibility in ('public','unlisted') or r.owner_id = auth.uid()))
);
create policy versions_insert_owner on public.room_versions for insert
with check (
exists (select 1 from public.rooms r
where r.id = room_id and r.owner_id = auth.uid())
);
-- UPDATE 仅供 Edge Function 通过 service_role 调用(绕过 RLS);
-- 显式 policy 也开给 owner,便于"重命名/重新触发"等运营动作。
create policy versions_update_owner on public.room_versions for update
using (
exists (select 1 from public.rooms r
where r.id = room_id and r.owner_id = auth.uid())
);
create policy versions_delete_owner on public.room_versions for delete
using (
exists (select 1 from public.rooms r
where r.id = room_id and r.owner_id = auth.uid())
);
```
### 3.4 `layers`
> 仅落 `walls / floor / furniture` 三层(材质走 manifest 内嵌,见 D4)。一行 = 一层;`manifest_node` 是该层在 `layer_manifest.json` 中的子树拷贝(冗余存储,便于 Postgres 端聚合查询,不必每次拉 Storage)。
```sql
create table public.layers (
id uuid primary key default gen_random_uuid(),
version_id uuid not null references public.room_versions(id) on delete cascade,
layer_kind layer_kind not null,
-- 节点数 / bbox 体积等便于排序/筛选的快查字段
item_count int not null default 0,
bbox_volume numeric(10,3),
manifest_node jsonb not null,
unique (version_id, layer_kind)
);
create index layers_version on public.layers (version_id);
create index layers_kind on public.layers (layer_kind);
alter table public.layers enable row level security;
create policy layers_select_via_version on public.layers for select
using (
exists (select 1 from public.room_versions v
join public.rooms r on r.id = v.room_id
where v.id = version_id
and (r.visibility in ('public','unlisted') or r.owner_id = auth.uid()))
);
create policy layers_write_via_owner on public.layers for all
using (
exists (select 1 from public.room_versions v
join public.rooms r on r.id = v.room_id
where v.id = version_id and r.owner_id = auth.uid())
)
with check (
exists (select 1 from public.room_versions v
join public.rooms r on r.id = v.room_id
where v.id = version_id and r.owner_id = auth.uid())
);
```
### 3.5 `redactions`(隐私脱敏)
```sql
create table public.redactions (
id uuid primary key default gen_random_uuid(),
version_id uuid not null references public.room_versions(id) on delete cascade,
kind redaction_kind not null,
-- region 统一用归一化坐标:
-- 2D(贴图上):{"space":"texture","tex_id":"...", "bbox":[x,y,w,h]}0-1
-- 3D(世界系):{"space":"world", "obb":{"center":[x,y,z],
-- "extent":[ex,ey,ez],"quat":[w,x,y,z]}}
region jsonb not null,
source text not null check (source in ('auto_vision','user','moderator')),
confidence numeric(4,3), -- 0-1,仅 source='auto_vision' 时有意义
applied_at timestamptz not null default now(),
note text
);
create index redactions_version on public.redactions (version_id);
create index redactions_kind on public.redactions (kind);
alter table public.redactions enable row level security;
-- 只有 owner 可读完整列表(避免攻击者通过脱敏记录反推敏感位置)
create policy redactions_owner_only on public.redactions
for all using (
exists (select 1 from public.room_versions v
join public.rooms r on r.id = v.room_id
where v.id = version_id and r.owner_id = auth.uid())
);
```
### 3.6 `remixes`
```sql
create table public.remixes (
id uuid primary key default gen_random_uuid(),
parent_version_id uuid not null references public.room_versions(id) on delete restrict,
author_id uuid not null references public.users(id) on delete cascade,
title text not null check (char_length(title) between 1 and 120),
overlay jsonb not null, -- remix_overlay.json,详见 02_api_contract.md §4
thumbnail_path text,
is_public boolean not null default true,
like_count int not null default 0,
created_at timestamptz not null default now(),
-- v0.2 / G-9Remix 软删(作者注销时统一走软删流;30 天可恢复)
deleted_at timestamptz null,
-- v0.2 / G-1:父房间硬删时由 Edge Function room-delete-with-snapshot 写入此字段
-- 值形如 'public/remix-fallbacks/{parent_room_id}/v{n}/canonical.glb'
-- 表示该 Remix 已脱离原父版本,几何由平台镜像承载(详见 10_governance §4 P-W-3
parent_snapshot_path text null
);
create index remixes_parent on public.remixes (parent_version_id);
create index remixes_author on public.remixes (author_id);
create index remixes_public_recent
on public.remixes (created_at desc) where is_public;
alter table public.remixes enable row level security;
create policy remixes_select_public on public.remixes for select
using (is_public or author_id = auth.uid());
create policy remixes_insert_self on public.remixes for insert
with check (
author_id = auth.uid()
and exists (
select 1 from public.room_versions v
join public.rooms r on r.id = v.room_id
where v.id = parent_version_id
and v.status = 'ready'
and r.visibility in ('public','unlisted'))
);
create policy remixes_update_owner on public.remixes for update
using (author_id = auth.uid()) with check (author_id = auth.uid());
create policy remixes_delete_owner on public.remixes for delete
using (author_id = auth.uid());
```
> 父版本删除策略选 `on delete restrict` 而不是 `cascade`——见 [`02_api_contract.md`](02_api_contract.md) §7 错误码 `REMIX_PARENT_DELETED` 的解释(删除前必须先迁移到 tombstone 或转为软删除)。
### 3.7 `comments`
```sql
create table public.comments (
id uuid primary key default gen_random_uuid(),
room_id uuid not null references public.rooms(id) on delete cascade,
author_id uuid not null references public.users(id) on delete cascade,
body text not null check (char_length(body) between 1 and 1000),
reply_to uuid references public.comments(id) on delete set null,
created_at timestamptz not null default now(),
-- v0.2 / G-9:评论软删(作者删评论 / 注销 → 标 deleted_at30 天后硬删)
deleted_at timestamptz null
);
create index comments_room on public.comments (room_id, created_at desc);
create index comments_author on public.comments (author_id);
alter table public.comments enable row level security;
create policy comments_select_public on public.comments for select
using (
exists (select 1 from public.rooms r
where r.id = room_id
and (r.visibility in ('public','unlisted') or r.owner_id = auth.uid()))
);
create policy comments_insert_self on public.comments for insert
with check (author_id = auth.uid());
create policy comments_delete_owner_or_room on public.comments for delete
using (
author_id = auth.uid()
or exists (select 1 from public.rooms r
where r.id = room_id and r.owner_id = auth.uid())
);
```
### 3.8 `likes`
```sql
create table public.likes (
id uuid primary key default gen_random_uuid(),
room_id uuid not null references public.rooms(id) on delete cascade,
user_id uuid not null references public.users(id) on delete cascade,
created_at timestamptz not null default now(),
-- v0.2 / G-9:点赞软删(多用于注销级联软删;用户手动「取消点赞」走 DELETE 物理删)
deleted_at timestamptz null,
unique (room_id, user_id)
);
create index likes_room on public.likes (room_id);
create index likes_user on public.likes (user_id);
alter table public.likes enable row level security;
create policy likes_select_public on public.likes for select using (true);
create policy likes_insert_self on public.likes for insert
with check (user_id = auth.uid());
create policy likes_delete_self on public.likes for delete
using (user_id = auth.uid());
-- 计数同步触发器(避免每次 select count(*)
create function bump_room_like_count() returns trigger as $$
begin
if tg_op = 'INSERT' then
update public.rooms set like_count = like_count + 1 where id = new.room_id;
elsif tg_op = 'DELETE' then
update public.rooms set like_count = greatest(0, like_count - 1) where id = old.room_id;
end if;
return null;
end $$ language plpgsql;
create trigger likes_count_after
after insert or delete on public.likes
for each row execute function bump_room_like_count();
```
### 3.9 `assets`(公共素材库)
```sql
create table public.assets (
id uuid primary key default gen_random_uuid(),
kind asset_kind not null,
name text not null,
semantic_class text, -- 对齐 RoomPlan 16 类家具,如 'sofa','table'
glb_path text, -- furniture 用
pbr jsonb, -- material 用:{base_color, normal, roughness, metallic, ao}
thumbnail_path text not null,
license text not null default 'CC0',
source_url text,
tags text[] not null default '{}',
created_at timestamptz not null default now()
);
create index assets_kind on public.assets (kind);
create index assets_class on public.assets (semantic_class);
create index assets_tags on public.assets using gin (tags);
alter table public.assets enable row level security;
create policy assets_select_all on public.assets for select using (true);
-- INSERT/UPDATE/DELETE 仅 service_role(运营后台),不写 policy 即可关闭
```
### 3.10 外键级联策略一览
| 父表 → 子表 | on delete | 理由 |
|------------|-----------|------|
| `auth.users → public.users` | cascade | 注销账号即清影子表 |
| `users → rooms / remixes / comments / likes` | cascade | 用户注销即清其内容(GDPR) |
| `rooms → room_versions / comments / likes` | cascade | 删房即清版本与互动 |
| `room_versions → layers / redactions` | cascade | 版本即子树根 |
| `room_versions → remixes` | **restrict** | 父被引用则禁止物理删除,必须先 tombstone |
| `users → assets` | n/a | 公共素材库与用户解耦 |
> 🔄 **v0.2 — 回写自 G-1**:上表中 `room_versions → remixes` 的 `restrict` 语义在 v0.2 起由 Edge Function `room-delete-with-snapshot`[`02_api_contract.md`](02_api_contract.md) §2 E-16)显式兑现:**父 room 硬删时,service_role 必须遍历所有指向该 room 任一 version 的 `remixes` 行,把对应 `room_versions.canonical_glb_path / manifest_path` 复制到 `public/remix-fallbacks/{parent_room_id}/v{n}/` 下,并把新路径写入 `remixes.parent_snapshot_path`**,然后才允许 `delete from public.rooms where id = $1`。该步骤失败时回滚整个事务并返回业务码 `PARENT_SNAPSHOT_TRANSFER_FAILED`[`02_api_contract.md`](02_api_contract.md) §7)。注意:**只复制几何与 manifest,不复制 `redactions[]` 表行**——这是 [`10_governance.md`](10_governance.md) §4 P-W-3 与 [`09_privacy.md`](09_privacy.md) §4 P-2 的隐私边界对齐。
### 3.11 软删 vs 硬删分层策略(v0.2 新增)
> 🔄 **v0.2 — 回写自 G-9**:本节根据 [`09_privacy.md`](09_privacy.md) §4 P-4「注销账号 T+0 / T+7 / T+30 三阶段」追加。所有 `deleted_at` 字段(§3.1 / §3.2 / §3.3 / §3.6 / §3.7 / §3.8)共享下述策略;表 `layers` / `redactions` / `assets` **不**加 `deleted_at`(前两者随 `room_versions` cascade`assets` 是平台资产无用户归属)。
#### 3.11.1 两层删除模型
| 层 | 调用方 | 操作 | 数据状态 |
|----|--------|------|---------|
| **业务 APIPostgREST + Edge Function** | iOS / Web 终端 | `UPDATE ... SET deleted_at = now()` | 行物理保留;现有 RLS 通过 `AND deleted_at IS NULL` 让普通查询「看不见」该行 |
| **service_role 物理删 cron** | 平台调度(`pg_cron`,每日 02:00 | `DELETE FROM ... WHERE deleted_at < now() - INTERVAL '30 days'` | 行真正消失;通过既有 ON DELETE CASCADE 链式清除子表 |
#### 3.11.2 现有 RLS policy 的 v0.2 补丁
所有 `*_select_*``*_select_via_*` policy 的 `USING` 子句需追加 `AND deleted_at IS NULL`(影子表 `users``rooms``room_versions``remixes``comments``likes`)。示例(以 `rooms` 表为例):
```sql
-- v0.2 补丁:在已有 policy 上叠加软删过滤
drop policy rooms_select_public on public.rooms;
create policy rooms_select_public on public.rooms for select
using (
deleted_at is null
and (visibility in ('public', 'unlisted') or owner_id = auth.uid())
);
-- room_versions / remixes / comments / likes / users 同理追加 `and deleted_at is null`
-- 注:owner 自查时也走 deleted_at is null;如需查看自己的「回收站」走单独 RPC,
-- 由 Edge Function account-export02_api_contract.md §2 E-18)暴露。
```
#### 3.11.3 软删触发与物理删 cron
```sql
-- 账号注销:业务 API 不直接走 SQL,而是调 Edge Function account-delete (E-17)
-- 该函数在 service_role 下执行:
-- step 1 (T+0): UPDATE users SET deleted_at=now() WHERE id=$1;
-- UPDATE rooms SET visibility='private', deleted_at=now() WHERE owner_id=$1;
-- UPDATE remixes/comments/likes SET deleted_at=now() WHERE author_id/user_id=$1;
-- -- auth.users JWT 即刻失效
-- step 2 (T+7): 业务 API 拒绝撤销请求(403 ACCOUNT_DELETION_IN_PROGRESS
-- step 3 (T+30): 走下述 pg_cron 物理删
-- pg_cron 每日 02:00 物理删 (示意)
-- 删 users 前必须先处理其 rooms 的 remix 快照转移 (G-1 / E-16 流程)
select cron.schedule('crowdroom_hard_delete', '0 2 * * *', $$
-- 1. 先对 rooms 走快照转移 (服务端调 room-delete-with-snapshot 等价逻辑)
-- 2. 再 cascade 删 users
delete from public.users where deleted_at < now() - interval '30 days';
delete from public.rooms where deleted_at < now() - interval '30 days';
delete from public.remixes where deleted_at < now() - interval '30 days';
delete from public.comments where deleted_at < now() - interval '30 days';
delete from public.likes where deleted_at < now() - interval '30 days';
delete from public.room_versions where deleted_at < now() - interval '30 days';
$$);
```
#### 3.11.4 与 RLS 现状的兼容性
- **service_role 绕过 RLS**cron 物理删与快照转移用 service_role JWT,不受软删过滤影响
- **owner 自查回收站**:MVP 不开放 UI 入口;如需,走 Edge Function `account-export`E-18)一次性导出全部 `deleted_at IS NOT NULL`
- **审计需求**:审计日志 / Sentry 事件需关联软删用户的 `id` 时,从 `auth.users` 影子表查(保留期受 [`09_privacy.md`](09_privacy.md) §8 保留期表约束)
---
## 4. Storage 目录结构
Supabase Storage 用两个 bucket
| Bucket | 公私 | 内容 |
|--------|------|------|
| `rooms` | **public**(公开作品的 glb/缩略图/manifest 走 CDN | 每个房间一棵子树 |
| `private` | **private**(原始 `.usdz` / `.roomplan.json` / 失败转码日志) | 仅 owner + service_role 可访问 |
### 4.1 公开 bucket 目录
```
rooms/{room_id}/v{version_no}/
├── canonical.glb # Web 端拉取的唯一几何文件(Draco 压缩)
├── layer_manifest.json # 4 层索引,详见 §5
├── thumbnail.webp # 640×360 主缩略图
├── thumbnail@2x.webp # 1280×720 高清版
├── preview.mp4 # 可选:5s 360° 自动旋转预览
└── overlays/
└── {remix_id}.json # 该房间衍生的 remix overlay 副本(CDN 缓存)
remix-fallbacks/{parent_room_id}/v{parent_version_no}/ # v0.2 / G-1 新增
├── canonical.glb # 父硬删时从原 rooms/.../ 复制过来的几何镜像
└── layer_manifest.json # 同上;redactions 表行不复制(隐私边界,见 §3.10 v0.2 段)
```
> 🔄 **v0.2 — 回写自 G-1**:上图新增 `remix-fallbacks/` 顶级目录。父房间硬删流程(Edge Function `room-delete-with-snapshot` / [`02_api_contract.md`](02_api_contract.md) §2 E-16)按下列顺序执行:
>
> 1. 遍历该 room 的所有 `version_no` 中所有 `is_public=true AND deleted_at IS NULL` 的 Remix 子代
> 2. 对每个被引用的 `room_versions` 行,把 `canonical_glb_path` 与 `manifest_path` 指向的对象**复制**(不是 move,避免中途失败丢父)到 `remix-fallbacks/{parent_room_id}/v{n}/canonical.glb` 与 `.../layer_manifest.json`
> 3. 更新 `remixes.parent_snapshot_path = 'remix-fallbacks/{parent_room_id}/v{n}/canonical.glb'`(事务内)
> 4. 全部 Remix 写完后再执行 `delete from public.rooms where id = $1`cascade 子表与原 Storage 路径清理)
> 5. 任一步失败 → 整体事务回滚 + Storage 复制产物垃圾回收(异步) + 返回 `PARENT_SNAPSHOT_TRANSFER_FAILED`
>
> 该目录的 RLS / CDN 缓存策略与 `rooms/` 相同(public bucketCDN 可缓存);Web Remix 详情页加载几何时优先看 `remixes.parent_snapshot_path` 是否非空,非空则从 fallback 路径加载,否则从原父 `rooms/.../` 加载。
### 4.2 私有 bucket 目录
```
private/rooms/{room_id}/v{version_no}/
├── source.usdz # 原始 RoomPlan 导出
├── source.roomplan.json # CapturedRoom JSON(含 walls/doors/windows/objects
├── transcode.log # Worker 日志(含失败堆栈)
└── pre_redaction.jpg # 脱敏前原图缩略(仅当 owner 在 App 内勾选"保留备份"
```
### 4.3 命名约定
- `room_id` 用 UUID v4 的 32 位 hex(无 `-`),路径更短:`rooms/8f1c.../v1/...`
- `version_no` 从 1 递增;删除版本不复用号
- 公开 bucket 对象走 CDNURL 形如 `https://{project}.supabase.co/storage/v1/object/public/rooms/{room_id}/v{n}/canonical.glb`
- 私有 bucket 由 Edge Function 签发 presigned URLTTL 默认 60 s(上传)/ 600 s(下载)
- 所有写入路径在 Worker 端走 `{room_id}/v{n}/.tmp/` 暂存目录,转码完成后 `rename` 到正式路径,避免半成品被读到
---
## 5. `layer_manifest.json` JSON SchemaDraft 2020-12
`layer_manifest.json` 是 Web 端的**入口文件**:拉一次就能拿到 4 层结构、每层节点 ID、家具语义、材质槽位,再按需 lazy 加载 `canonical.glb` 的子树。
### 5.1 设计约束
| 约束 | 说明 |
|------|------|
| **4 层固定** | `layers` 必须正好包含 `walls / floor / furniture / materials` 4 个键,缺一则 manifest 无效 |
| **节点指向 .glb** | `mesh_node_ids[]` 中每个 ID 必须能在 `canonical.glb` 中通过 `node.name == id` 找到 |
| **材质是逻辑层** | `materials.slots[]` 引用其它三层中的 `target_mesh_id`,不持有几何 |
| **家具语义对齐 RoomPlan** | `semantic_class` 取值限定在 RoomPlan 16 类(见 §5.3 |
| **bbox 单位** | 米(meters);坐标系右手、+Y 向上(与 glTF 一致;RoomPlan 原始 +Z 向上由 Worker 转换) |
### 5.2 JSON Schema 定义
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://crowdroom.app/schemas/layer_manifest.v1.json",
"title": "CrowdRoom Layer Manifest",
"type": "object",
"required": ["schema_version", "room_id", "version_no", "glb_uri", "layers"],
"additionalProperties": false,
"properties": {
"schema_version": { "const": "1.0.0" },
"room_id": { "type": "string", "pattern": "^[0-9a-f]{32}$" },
"version_no": { "type": "integer", "minimum": 1 },
"glb_uri": { "type": "string", "format": "uri-reference" },
"coordinate_system": {
"type": "object",
"properties": {
"handedness": { "const": "right" },
"up_axis": { "const": "+Y" },
"unit": { "const": "meter" }
},
"required": ["handedness", "up_axis", "unit"]
},
"room_metrics": {
"type": "object",
"description": "从 RoomPlan JSON 汇总",
"properties": {
"floor_area_m2": { "type": "number", "minimum": 0 },
"ceiling_height_m": { "type": "number", "minimum": 0 },
"wall_count": { "type": "integer", "minimum": 0 },
"door_count": { "type": "integer", "minimum": 0 },
"window_count": { "type": "integer", "minimum": 0 },
"furniture_count": { "type": "integer", "minimum": 0 }
}
},
"layers": {
"type": "object",
"required": ["walls", "floor", "furniture", "materials"],
"additionalProperties": false,
"properties": {
"walls": { "$ref": "#/$defs/structuralLayer" },
"floor": { "$ref": "#/$defs/structuralLayer" },
"furniture": { "$ref": "#/$defs/furnitureLayer" },
"materials": { "$ref": "#/$defs/materialsLayer" }
}
}
},
"$defs": {
"bbox": {
"type": "object",
"required": ["min", "max"],
"properties": {
"min": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
"max": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 }
}
},
"obb": {
"type": "object",
"required": ["center", "extent", "quat"],
"properties": {
"center": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
"extent": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
"quat": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4,
"description": "[w,x,y,z]" }
}
},
"structuralLayer": {
"type": "object",
"required": ["mesh_node_ids", "bbox", "category", "replaceable"],
"properties": {
"mesh_node_ids": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"bbox": { "$ref": "#/$defs/bbox" },
"category": { "type": "string", "enum": ["wall", "floor", "ceiling"] },
"replaceable": { "type": "boolean", "description": "材质是否可换;几何不可换" }
}
},
"furnitureLayer": {
"type": "object",
"required": ["items"],
"properties": {
"items": {
"type": "array",
"items": { "$ref": "#/$defs/furnitureItem" }
}
}
},
"furnitureItem": {
"type": "object",
"required": ["item_id", "mesh_node_ids", "obb", "anchor_point", "semantic_class", "replaceable"],
"properties": {
"item_id": { "type": "string" },
"mesh_node_ids": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"obb": { "$ref": "#/$defs/obb" },
"anchor_point": { "type": "array", "items": { "type": "number" },
"minItems": 3, "maxItems": 3,
"description": "替换家具时新模型应贴合的世界点(通常是 OBB 底面中心)" },
"semantic_class": {
"type": "string",
"enum": ["storage", "refrigerator", "stove", "bed", "sink", "washer_dryer",
"toilet", "bathtub", "oven", "dishwasher", "table", "sofa",
"chair", "fireplace", "television", "stairs"]
},
"replaceable": { "type": "boolean" },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
}
},
"materialsLayer": {
"type": "object",
"required": ["slots"],
"properties": {
"slots": {
"type": "array",
"items": { "$ref": "#/$defs/materialSlot" }
}
}
},
"materialSlot": {
"type": "object",
"required": ["slot_id", "target_mesh_id", "uv_channel", "pbr_defaults"],
"properties": {
"slot_id": { "type": "string" },
"target_mesh_id": { "type": "string",
"description": "必须出现在 walls/floor/furniture 的 mesh_node_ids 中" },
"uv_channel": { "type": "integer", "minimum": 0, "maximum": 3 },
"pbr_defaults": {
"type": "object",
"required": ["base_color", "roughness", "metallic"],
"properties": {
"base_color": { "type": "array", "items": { "type": "number" },
"minItems": 4, "maxItems": 4,
"description": "RGBA 0-1" },
"base_color_tex": { "type": "string", "description": "可选贴图 URI" },
"normal_tex": { "type": "string" },
"roughness": { "type": "number", "minimum": 0, "maximum": 1 },
"metallic": { "type": "number", "minimum": 0, "maximum": 1 },
"ao_tex": { "type": "string" }
}
},
"replaceable": { "type": "boolean", "default": true }
}
}
}
}
```
### 5.3 完整示例(≥50 行)
```json
{
"schema_version": "1.0.0",
"room_id": "8f1c2a4d6b9e4f0e8a7c3d2b1f5e9a0c",
"version_no": 1,
"glb_uri": "rooms/8f1c2a4d6b9e4f0e8a7c3d2b1f5e9a0c/v1/canonical.glb",
"coordinate_system": { "handedness": "right", "up_axis": "+Y", "unit": "meter" },
"room_metrics": {
"floor_area_m2": 18.4,
"ceiling_height_m": 2.72,
"wall_count": 5,
"door_count": 1,
"window_count": 2,
"furniture_count": 6
},
"layers": {
"walls": {
"mesh_node_ids": ["wall_0", "wall_1", "wall_2", "wall_3", "wall_4"],
"bbox": { "min": [-2.8, 0.0, -3.1], "max": [2.8, 2.72, 3.1] },
"category": "wall",
"replaceable": true
},
"floor": {
"mesh_node_ids": ["floor_0"],
"bbox": { "min": [-2.8, 0.0, -3.1], "max": [2.8, 0.02, 3.1] },
"category": "floor",
"replaceable": true
},
"furniture": {
"items": [
{
"item_id": "bed_001",
"mesh_node_ids": ["furn_bed_001"],
"obb": {
"center": [-0.5, 0.30, -1.4],
"extent": [2.00, 0.60, 1.50],
"quat": [1.0, 0.0, 0.0, 0.0]
},
"anchor_point": [-0.5, 0.0, -1.4],
"semantic_class": "bed",
"replaceable": true,
"confidence": 0.94
},
{
"item_id": "table_001",
"mesh_node_ids": ["furn_table_001"],
"obb": {
"center": [1.2, 0.38, 0.5],
"extent": [1.20, 0.04, 0.60],
"quat": [0.924, 0.0, 0.383, 0.0]
},
"anchor_point": [1.2, 0.0, 0.5],
"semantic_class": "table",
"replaceable": true,
"confidence": 0.88
},
{
"item_id": "tv_001",
"mesh_node_ids": ["furn_tv_001"],
"obb": {
"center": [0.0, 1.20, -2.95],
"extent": [1.10, 0.65, 0.08],
"quat": [1.0, 0.0, 0.0, 0.0]
},
"anchor_point": [0.0, 1.20, -2.95],
"semantic_class": "television",
"replaceable": true,
"confidence": 0.91
}
]
},
"materials": {
"slots": [
{
"slot_id": "mat_wall_paint",
"target_mesh_id": "wall_0",
"uv_channel": 0,
"pbr_defaults": {
"base_color": [0.93, 0.91, 0.88, 1.0],
"roughness": 0.85,
"metallic": 0.0
},
"replaceable": true
},
{
"slot_id": "mat_floor_wood",
"target_mesh_id": "floor_0",
"uv_channel": 0,
"pbr_defaults": {
"base_color": [0.55, 0.40, 0.28, 1.0],
"base_color_tex": "rooms/8f1c.../v1/tex/floor_wood_diffuse.webp",
"normal_tex": "rooms/8f1c.../v1/tex/floor_wood_normal.webp",
"roughness": 0.62,
"metallic": 0.0
},
"replaceable": true
},
{
"slot_id": "mat_bed_fabric",
"target_mesh_id": "furn_bed_001",
"uv_channel": 0,
"pbr_defaults": {
"base_color": [0.95, 0.95, 0.95, 1.0],
"roughness": 0.78,
"metallic": 0.0
},
"replaceable": true
}
]
}
}
}
```
---
## 6. CrowdRoom 4 层 ↔ PRISM L1L4 映射
参考 [`plans/PRISM/03_data_schema.md`](../PRISM/03_data_schema.md) §3.2 的 `MemoryLevel`。CrowdRoom 是 PRISM 的**降维投影**:保留消费级展示需要的几何 + 语义,丢掉机器人专用的稠密/时序/翻案字段。
| CrowdRoom 层 | PRISM 对应 | 降维说明(保留什么) | 丢弃的信息 |
|--------------|-----------|----------------------|------------|
| `walls` | **L2 度量**`category='wall'` 的 SpatialNode `polygon_2d + bbox_3d` | 仅保留墙面 mesh + bbox + 可否换材质标记 | TSDF/OctoMap 稠密体素、`no_update_zone`(镜面)、墙面厚度的多次测量历史 |
| `floor` | **L2 度量**`category='floor'` 节点的 `mesh_uri` | 单一地面 mesh + bbox | 高程网格、3DGS 高斯、地面材质多视角光照 |
| `furniture` | **L4 语义**`category='furniture'` 的 SpatialNode(每件 1 节点) | `item_id / obb / anchor_point / semantic_class / confidence` | `keyframe_evidence`per-frame 翻案)、`clip_embedding`512D 向量)、`attributes.mobile/fragile/state``parent_room``SpatialEdge` 关系(on/under/next_to |
| `materials` | **L4 语义** 节点的 `attributes.material` + L2 mesh 的 UV/texture | PBR 槽位(base_color/roughness/metallic + 贴图 URI | 偏振材质属性、物理摩擦/密度(PRISM `material_props.json`)、各向异性反射 |
| 〔无对应〕 | **L1 感知缓冲** | — | CrowdRoom 不保留 keyframe RGB/Depth 流(隐私 + 体积,整馆 ~1GB) |
| 〔无对应〕 | **L3 拓扑** | — | CrowdRoom 单房间作品,无房间间拓扑边;楼层拼接划在 P2 |
**关键退化**CrowdRoom 的"4 层"是**展示导向**,不是"L1/L2/L3/L4"四层。同样叫"layer",但语义不同——前者是 UI 复选框,后者是认知层级。本表确保两套术语在交界处不冲突。
---
## 7. 隐私脱敏元数据补充
[`redactions`](#35-redactions隐私脱敏) 表配合 [`00_overview.md`](00_overview.md) §8 RK-4 的承诺,落地以下行为:
| `kind` | 触发方 | 典型 `region` | 应用阶段 |
|--------|--------|---------------|----------|
| `face` | iOS 端 Vision 自动检测 | 2D 贴图坐标 bbox(`space=texture`) | 上传前端侧模糊,服务端冗余存 region 便于举报复核 |
| `mirror` | RoomPlan 法向 + 反射强度启发式 | 3D world OBB`space=world` | Web 端渲染时叠加"反射区域"图标提醒 |
| `logo` | iOS Vision 文字/品牌检测 | 2D 贴图 bbox | 上传前端侧模糊 |
| `plate` | 同 face,针对车牌/身份证 | 2D 贴图 bbox | 同 face |
| `user_marked` | App 内"涂抹敏感区"工具 | 3D world OBB 或 2D bbox | 在 manifest 中标 `obscured_node_ids[]`Web 端整节点替换为占位 |
**RLS 选择**`redactions` 表只有 owner 自己可读全量(policy `redactions_owner_only`)。第三方只能间接看到"该区域有内容被脱敏"manifest 内嵌的 `obscured_node_ids`),看不到 region 坐标,避免攻击者通过坐标反推真实人脸/证件位置。
---
## 8. 本章小结与对外契约
| 契约 | 给谁 | 一句话 |
|------|------|--------|
| **9 表(含 `public.users`+ 完整 RLS** | iOS / Web / Worker | 客户端永远用 anon JWT 走 PostgRESTWorker 用 service_role 绕过 RLS 写 `room_versions.status` |
| **Storage 双 bucket 模型** | iOS / Worker | `public/rooms/`CDN 可缓存)+ `private/rooms/`(原始 .usdz/JSON 不直出) |
| **`layer_manifest.json` v1.0** | Worker(生产)/ Web(消费) | 4 层固定、家具 16 类、材质走 slots;任何字段缺失视为 manifest 无效(错误码 `LAYER_MANIFEST_INVALID` |
| **PRISM 兼容** | 未来"用户贡献先验"链路 | CrowdRoom 节点可被映射回 PRISM L2/L4 SpatialNode,但不携带 L1 keyframe 与 L3 拓扑 |
| **隐私默认开** | iOS App | `redactions` 至少包含 `face` 自动检测条目(即便为 0 个面孔,也应写一条 `kind=face, region={"empty":true}` 表示已扫描) |
下一章 [`02_api_contract.md`](02_api_contract.md) 在此 schema 上定义端点、Edge Function 与转码流水。
---
**章节版本**v0.1 · 草案
**关键收获**CrowdRoom 落到 Supabase 上 = 9 张表 + 2 个 Storage bucket + 1 份 `layer_manifest.json` Schema;与 PRISM 的关系是"展示层 4 层 ≈ L2/L4 降维投影"。
+515
View File
@@ -0,0 +1,515 @@
# 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 或风控拦下。
+690
View File
@@ -0,0 +1,690 @@
# CrowdRoom · iOS App 设计(v0.2
> **版本**v0.22026-05-19
> **v0.2 修订**:回写 G-4(§8.4 PrivacyManifest 新节,Apple 强制项)、G-5(§8.1 `NSLocationWhenInUseUsageDescription` 文案明确「精确 GPS 不会上传」)、G-8(§1.2 IA 跳转图追加 `PrivacyDetail` 节点)。源决策见 [`09_privacy.md`](09_privacy.md) §4 P-2 / P-3 与 §6.2 C-P2-6。
> 本章承接 [`00_overview.md`](00_overview.md) §4 架构图、[`01_data_schema.md`](01_data_schema.md) 的 9 张表与 `layer_manifest.json` schema、以及 [`02_api_contract.md`](02_api_contract.md) §2 的 19 个端点(v0.2,原 16 个 + 3 个新增)与 §8.1 的 5 条 iOS 硬契约(iOS-X1 ~ iOS-X5),落地为一份**可直接交付给 iOS 工程团队**的 App 设计。
>
> 复用 [`plans/iphone/iphone_simplified_plan.md`](../iphone/iphone_simplified_plan.md) 的扫描引导思想与 [`plans/iphone/roomplan_accuracy_and_cad_export.md`](../iphone/roomplan_accuracy_and_cad_export.md) 的 RoomPlan 精度边界,但裁剪到「**只上传、不出图**」的轻量端。
>
> **iOS 端只做三件事:采集(RoomPlan+ 端侧脱敏(Vision + CIGaussianBlur+ 上传到 Supabase**。Remix 编辑、Web 渲染、转码、审核——一概不做。
---
## 1. iOS App 信息架构(IA
### 1.1 整体导航:5 Tab + 模态扫描
CrowdRoom iOS 采用 5 个底部 Tab + 一个独立模态扫描页的结构。**扫描入口故意放在 Tab Bar 正中间作为大按钮**(参考 Instagram Reels 的 + 设计),强化「采集」是 App 的第一动作。
| Tab | 图标 | 功能 |
|-----|------|------|
| 1. 发现 | 🔭 | 公共 feed、搜索、标签筛选 |
| 2. 通知 | 🔔 | 我的房间被 Remix / 评论 / 点赞、转码完成通知 |
| 3. 扫描 ★ | +(中间凸起大按钮) | **唤起 RoomCaptureView 模态**,全屏覆盖;扫描结束回 Tab 1 或我的 |
| 4. 我的房间 | 📁 | 我上传的 / 草稿 / 失败需要重试 |
| 5. 我的 | 👤 | Profile、配额、设置、登录态 |
> Remix 编辑入口**只在 Web 端**(见 [`02_api_contract.md`](02_api_contract.md) §4),iOS 端只提供「在浏览器中打开并 Remix」的深链接跳转,不放独立 Tab。
### 1.2 页面跳转图
```mermaid
graph TD
Launch[启动页]
Auth[登录 注册页]
AppleLogin[Sign in with Apple]
EmailLogin[邮箱登录]
TabDiscover[Tab1 发现 Feed]
TabNotif[Tab2 通知列表]
TabScan[Tab3 扫描入口大按钮]
TabMyRooms[Tab4 我的房间]
TabProfile[Tab5 我的]
SearchPage[搜索 Tag 筛选页]
RoomDetail[房间详情 360 预览]
CommentList[评论列表]
ReportSheet[举报弹层]
RemixLaunchSheet[Remix 跳转确认弹层]
WebRemix[Safari 跳转 Web Remix 编辑器]
ScanIntro[扫描引导页 三步]
ScanCapture[RoomPlan 扫描中]
ScanReview[扫描完成预览页]
Redacting[端侧脱敏进度页]
UploadForm[上传表单 标题 标签 可见性]
UploadProgress[上传进度页]
TranscodeWait[等待转码页 Realtime]
UploadDone[发布完成页]
DraftList[草稿列表]
QuotaPage[配额详情页]
SettingsPage[设置页]
PrivacyPage[隐私脱敏策略页]
PrivacyDetail[单房间 隐私详情 脱敏检测框列表]
AccountDelete[注销账号并删除全部数据]
Launch --> Auth
Auth --> AppleLogin
Auth --> EmailLogin
AppleLogin --> TabDiscover
EmailLogin --> TabDiscover
TabDiscover --> SearchPage
TabDiscover --> RoomDetail
SearchPage --> RoomDetail
RoomDetail --> CommentList
RoomDetail --> ReportSheet
RoomDetail --> RemixLaunchSheet
RemixLaunchSheet --> WebRemix
TabScan --> ScanIntro
ScanIntro --> ScanCapture
ScanCapture --> ScanReview
ScanReview --> Redacting
Redacting --> UploadForm
UploadForm --> UploadProgress
UploadProgress --> TranscodeWait
TranscodeWait --> UploadDone
UploadDone --> RoomDetail
TabNotif --> RoomDetail
TabMyRooms --> RoomDetail
TabMyRooms --> DraftList
DraftList --> UploadForm
TabProfile --> QuotaPage
TabProfile --> SettingsPage
SettingsPage --> PrivacyPage
SettingsPage --> AccountDelete
TabMyRooms --> RoomDetail
RoomDetail --> PrivacyDetail
```
> 共 **24 个页面节点**v0.2,原 22 个 + v0.2 / G-8 追加 `PrivacyDetail` + v0.2 / P-4 配套追加 `AccountDelete`)。
>
> 🔄 **v0.2 — 回写自 G-8**:上图追加 `PrivacyDetail` 节点(路径 `我的房间 → 单房间详情 → 隐私详情`),对应 [`09_privacy.md`](09_privacy.md) §4 P-2 「owner 可在『我的房间 → 隐私详情』查看每个 bbox 缩略图并对漏检/误检发起『申请重做』工单」。`AccountDelete` 节点为 [`09_privacy.md`](09_privacy.md) §4 P-4 三阶段注销流程的 iOS 入口(路径 `Tab5 我的 → 设置 → 注销账号并删除全部数据`),调用 [`02_api_contract.md`](02_api_contract.md) §2 E-18 `account-delete`。
---
## 2. 核心用户流程(Critical Flows
### 2.1 Flow A:扫描 → 端侧脱敏 → 三段式上传 → 等待转码 → 发布
> ✅ 契约 iOS-X1(端侧人脸检测 + 高斯模糊)、iOS-X2(三段式上传)、iOS-X3JSON 原样)、iOS-X4Realtime 订阅)、iOS-X5(预查配额)全部出现在此流程
```mermaid
sequenceDiagram
autonumber
participant U as 用户
participant App as iOS App
participant V as Vision CoreImage
participant E as Edge Function
participant S as Supabase Storage
participant DB as Postgres
participant W as Transcode Worker
participant RT as Realtime Channel
U->>App: 点击 Tab 中间 + 按钮
Note over App: iOS-X5 进扫描页前预查配额
App->>E: GET functions v1 quota
E-->>App: tier free storage used limit
alt 配额超限
App-->>U: 本地拦截 弹窗提示升级
end
U->>App: 走完三步引导 开始 RoomCaptureView
App->>App: RoomPlan 采集 5 10 分钟
U->>App: 点击完成
App->>App: 导出 source usdz 与 roomplan json
Note over App: iOS-X3 RoomPlan JSON 原样保留 不做坐标变换
Note over App,V: iOS-X1 端侧脱敏开始
App->>V: 解压 usdz 遍历每张贴图 PNG JPEG
V->>V: VNDetectFaceRectanglesRequest
V-->>App: 命中 face bbox list
App->>V: CIGaussianBlur radius 18 仅作用于命中区域
V-->>App: 模糊后贴图
App->>App: 重新打包 usdz 同时把 face bbox 累积到 redactions
U->>App: 填写标题 标签 可见性 公开 私有
App->>E: POST upload-init title tags bytes_source
E->>DB: insert rooms 与 room_versions status uploading
E->>S: sign presigned URL usdz 与 json
E-->>App: version_id presigned_usdz presigned_json
Note over App,S: iOS-X2 三段式 第二段 直传 Storage
App->>S: PUT source usdz 大于 30 MB 走分片 multipart
App->>S: PUT source roomplan json
App->>E: POST upload-complete version_id redactions
E->>DB: update status queued insert redactions rows
E->>W: POST enqueue version_id
Note over App,RT: iOS-X4 订阅 Realtime 不轮询
App->>RT: subscribe channel room room_id
W->>W: usdz to glb 分层标注 manifest 缩略图
W->>E: POST transcode-done status ready paths
E->>DB: update status ready current_version_id
E->>RT: broadcast event ready version_id
RT-->>App: WebSocket push 收到 ready
App-->>U: 弹卡片 你的扫描已上线 跳房间详情
```
### 2.2 Flow B:浏览 → 房间详情 → 触发 Remix → 跳 Web
> iOS 端不做 Remix 编辑(编辑在 Web 端 R3F 渲染器里),仅做发起入口。Universal Link 透传 `room_id + version_id`,落地 Web 域名 `crowdroom.app/r/{room_id}?remix=1`。
```mermaid
sequenceDiagram
autonumber
participant U as 用户
participant App as iOS App
participant PG as PostgREST
participant CDN as CDN
participant SF as SFSafariViewController
U->>App: Tab 发现 下拉刷新
App->>PG: GET rest v1 rooms visibility eq public order created_at desc limit 20
PG-->>App: Room 列表 含 thumbnail_path
App->>CDN: GET thumbnail webp 列表卡片
CDN-->>App: 缩略图
U->>App: 点击某张卡片
App->>PG: GET rest v1 rooms id eq room_id with current_version
PG-->>App: Room 详情
Note over App: iOS 端仅展示静态 360 缩略图轮播
Note over App: 不在端内渲染 glb 太重 留给 Web
U->>App: 点击 Remix 按钮
App-->>U: 弹层 提示 Remix 编辑器在浏览器中体验更好
U->>App: 点击 确认
App->>SF: 打开 https crowdroom app r room_id remix 1
SF-->>U: 进入 Web Remix 编辑器
```
### 2.3 Flow C:通知到达 → 跳详情
```mermaid
sequenceDiagram
autonumber
participant RT as Realtime Channel
participant App as iOS App 后台 或 前台
participant UN as UNUserNotificationCenter
participant U as 用户
participant PG as PostgREST
Note over App,RT: App 启动时 已订阅 user user_id 个人频道
RT-->>App: push event remix_created 或 comment_new
alt App 在前台
App-->>U: Toast 提示 你的房间被 Remix
else App 在后台
App->>UN: scheduleLocalNotification 标题 房间被 Remix
UN-->>U: 系统通知中心展示
U->>UN: 点击通知
UN->>App: 唤起 app 携带 deeplink room_id
end
App->>PG: GET rest v1 rooms id eq room_id
PG-->>App: Room 详情
App-->>U: 跳房间详情页 评论列表锚到该条
```
---
## 3. 关键技术栈与库选型表
| 模块 | 推荐方案 | 备选 | 一行理由 |
|------|---------|------|---------|
| 语言 / 最低 iOS | **Swift 5.9 + iOS 17.0** | iOS 16.0 | RoomPlan API 在 17+ 增加了 second-pass mesh 优化与更稳的 `MultiRoom` 支持;Realtime SDK 也对 17 友好 |
| UI 框架 | **SwiftUI 主,UIKit 局部桥接** | 纯 UIKit | SwiftUI 写 Tab/列表/表单效率高;扫描页的 `RoomCaptureView``UIViewControllerRepresentable` 桥接 |
| RoomPlan 集成 | **`RoomCaptureView` 标准引导** | `RoomCaptureSession` 自定义 | MVP 不重写引导动画;标准 View 自带语音/手势提示,省 2 周 UX 工时;后续如要换皮再切 Session |
| 端侧人脸检测 | **Vision `VNDetectFaceRectanglesRequest`** | CoreML + 自训模型 | 系统级、无依赖、iPhone 13+ 单张 1024×1024 < 80 msRevision 3 召回率够 |
| 贴图脱敏 | **Core Image `CIGaussianBlur` + 蒙版合成** | Metal Shader 自写 | CIFilter 链路成熟、GPU 加速、`CIContext` 直接渲到 `CGImage` 写回 PNG |
| .usdz 解包/重打包 | **`ModelIO` + `USDKit`iOS 17+ `Compression` framework** | 外挂 `usdzconvert` CLI | iOS 沙箱不能跑 CLI`ModelIO` 支持读 USD,贴图替换走解包→改文件→重压 zip(usdz 本质是 zip |
| 网络层 | **URLSession + async/await + `URLSessionUploadTask`background config** | Alamofire | 无三方依赖;`backgroundSessionConfiguration` 是 App 进后台续传的唯一官方路径 |
| Supabase SDK | **[supabase-swift](https://github.com/supabase-community/supabase-swift) ≥ 2.0** | 手撸 REST + WebSocket | 官方维护、Realtime channel + Auth + Storage 三件套统一 SDK |
| Realtime 监听 | **`supabase-swift` Realtime channel `room:{id}` + `user:{id}`** | 自建 SSE | iOS-X4 强制要求订阅模型;SDK 处理重连与心跳 |
| 本地缓存 | **SwiftDataiOS 17** | Core Data / Realm | 草稿、未完成上传任务、Feed 分页缓存;SwiftData 与 SwiftUI 双向绑定省胶水代码 |
| 性能/崩溃监控 | **MetricKit(系统)+ Sentry iOS SDK** | Firebase Crashlytics | MetricKit 拿 RoomPlan 期 GPU/热量数据;Sentry 与 Supabase 后端 Sentry 共享 issue 视图 |
| 深链接 | **Universal Linksapple-app-site-association** | URL Scheme | Web Remix 编辑器跳回 App、通知点击跳详情都走 UL;URL Scheme 不安全且会被 Safari 拦截 |
| 图像渲染 | **`Image(uiImage:)` + `AsyncImage`** + 自建磁盘缓存 | Kingfisher / SDWebImage | 列表缩略图为主,自建 LRU 200 MB 够用;不引三方避免主线程阻塞 |
---
## 4. 端侧脱敏管线详细设计(iOS-X1 落地)
> ✅ 契约 iOS-X1:上传前必须在端侧跑人脸检测 + 贴图高斯模糊,并把命中区域写入 `redactions[]`,不依赖服务端二次脱敏。本节是 X1 的**完整工程落地**。
### 4.1 管线流程
```mermaid
graph TD
A[RoomPlan 导出 source usdz] --> B[Compression 解包 usdz 到 tmp redact 目录]
B --> C[ModelIO 枚举所有 MDLTexture 引用]
C --> D[拿到贴图文件路径列表 PNG JPEG]
D --> E[对每张贴图执行 Vision 人脸检测]
E --> F{检测到人脸}
F -- 无 --> G[原图不动]
F -- 有 --> H[CIGaussianBlur radius 18 渲染整图]
H --> I[用人脸 bbox 做蒙版 仅模糊区域合成回原图]
I --> J[写回贴图文件 同名覆盖]
G --> K[累计到 redactions list]
J --> K
K --> L[Compression 重新打包 usdz]
L --> M[校验 sha256 与体积 失败回滚]
M --> N[redactions 数组准备 POST 到 upload-complete]
```
### 4.2 Swift 伪代码骨架
```swift
import RoomPlan
import Vision
import CoreImage
import ModelIO
import Compression
// 线
// RoomPlan sourceUSDZ URL
// usdz URL redactions
func redactUSDZ(at sourceURL: URL) async throws -> (URL, [Redaction]) {
// 1 usdz zip
let workDir = FileManager.default.temporaryDirectory
.appendingPathComponent("redact_\(UUID().uuidString)")
try unzipUSDZ(source: sourceURL, into: workDir)
// 2 ModelIO
let asset = MDLAsset(url: workDir.appendingPathComponent("scene.usdc"))
let textureURLs = collectTextureURLs(in: asset, workDir: workDir)
// 3 Vision +
var redactions: [Redaction] = []
let ctx = CIContext(options: [.useSoftwareRenderer: false])
for texURL in textureURLs {
guard let cgImage = loadCGImage(texURL) else { continue }
// 3 1 Vision
let request = VNDetectFaceRectanglesRequest()
request.revision = VNDetectFaceRectanglesRequestRevision3
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
try handler.perform([request])
guard let faces = request.results, !faces.isEmpty else { continue }
// 3 2 CIGaussianBlur
let ciOriginal = CIImage(cgImage: cgImage)
let blurred = ciOriginal
.applyingFilter("CIGaussianBlur", parameters: [kCIInputRadiusKey: 18.0])
.cropped(to: ciOriginal.extent)
// 3 3 bbox
let maskCI = buildFaceMask(faces: faces, extent: ciOriginal.extent)
let composited = blurred.applyingFilter(
"CIBlendWithMask",
parameters: [
kCIInputBackgroundImageKey: ciOriginal,
kCIInputMaskImageKey: maskCI
]
)
// 3 4 ModelIO
guard let outCG = ctx.createCGImage(composited, from: composited.extent) else { continue }
try writePNG(outCG, to: texURL)
// 3 5 redactions POST Edge Function
for face in faces {
redactions.append(Redaction(
textureRef: texURL.lastPathComponent,
kind: "face",
region: face.boundingBox, // 0 1
method: "gaussian_blur_r18"
))
}
}
// 4 usdz
let redactedURL = workDir.appendingPathComponent("redacted.usdz")
try zipUSDZ(folder: workDir, output: redactedURL)
try validatePackaged(redactedURL)
return (redactedURL, redactions)
}
```
> 关键点:`usdz` 是无压缩 zip,重新打包必须用 `compression_encode_buffer` 的 `COMPRESSION_LZFSE_NONE` 或直接走 store-only zip——否则 Apple 工具链识别不出来。
### 4.3 性能预算
| 阶段 | iPhone 15 Pro 目标 | iPhone 13 Pro 目标 | 备注 |
|------|------------------|------------------|------|
| 解包 60 MB usdz | < 0.5 s | < 1.0 s | I/O bound |
| Vision 检测 20 张 1024² 贴图 | < 2.5 s | < 4.5 s | Neural Engine |
| CIGaussianBlur + 合成 | < 1.5 s | < 2.5 s | GPU bound |
| 重打包 | < 0.5 s | < 1.0 s | I/O |
| **总计** | **≤ 8 s** | **≤ 12 s** | iPhone 12 Pro 预期 16 s 见 §10 风险 |
UI:脱敏期间显示带百分比的进度页,文案「正在检查照片中的人脸…」。
### 4.4 失败兜底
- **重试机制**:单张贴图脱敏失败(如 ModelIO 解不开纹理)→ 重试 1 次。整体管线失败 → 重试最多 3 次。
- **3 次失败后**:弹窗提示「我们没能自动模糊照片里的人脸。建议你**关闭含人的扫描区域重扫**,或**手动框选要模糊的区域**」,给出两个按钮:「重新扫描」与「手动标注后上传」。
- **手动标注降级**:进入一个 `UIScrollView` 缩略图墙,让用户长按贴图后框选矩形,前端写到 `redactions[kind=manual]`,仍走相同上传流。
- **「不脱敏直接上传」按钮一律不提供**——这是 iOS-X1 的硬约束。
---
## 5. 三段式上传实现(iOS-X2 落地)
> ✅ 契约 iOS-X2:上传走 `upload-init → 直传 Storage → upload-complete` 三步,禁止把 `.usdz` 字节流塞进 Edge Function body。
### 5.1 顺序图
```mermaid
sequenceDiagram
autonumber
participant App as iOS App
participant E as Edge Function
participant S as Supabase Storage
App->>E: POST upload-init title tags bytes_source
E-->>App: version_id presigned_usdz presigned_json expires_at
App->>App: 创建 URLSession backgroundConfiguration identifier crowdroom upload version_id
par 并行 双 PUT
App->>S: PUT source usdz 大于 30 MB 走 multipart 5 MB chunk
App->>S: PUT source roomplan json
end
S-->>App: 200 OK 两次
App->>E: POST upload-complete version_id redactions
E-->>App: status queued
```
### 5.2 关键实现要点
| 场景 | 策略 |
|------|------|
| **大文件分片** | `.usdz > 30 MB` 启用 multipart PUT5 MB 一片,并行 3 路;Supabase Storage 支持 S3 兼容的 multipart |
| **断点续传** | 用 `URLSessionUploadTask` + 自管理 `Range` 头;SwiftData 表 `upload_chunks` 记录每片状态(pending/sent/acked),App 重启后扫表续传 |
| **App 进后台** | `URLSession(configuration: .background(withIdentifier:))` 让系统在 App 被挂起后继续传;完成时通过 `application(_:handleEventsForBackgroundURLSession:completionHandler:)` 唤醒 |
| **网络切换** | 注册 `NWPathMonitor`,从 Wi-Fi 切到蜂窝时**暂停**上传并弹窗:「当前已切换到蜂窝网络,继续上传可能产生流量费用」(默认开关:仅 Wi-Fi) |
| **电量低于 20%** | 启动上传前查 `UIDevice.current.batteryLevel`,< 0.20 时弹窗「电量较低,是否仍继续上传?」并禁用 multipart 并行(降为 1 路) |
| **超时与退避** | 单片超时 60 s,整体 20 分钟;指数退避 5 s → 15 s → 45 s,3 次失败后转 `failed` 入草稿 |
| **presigned 过期** | `expires_at` 提前 30 s 触发 `upload-init` 重签;server-side 已设 15 min TTL,足以覆盖大文件 |
### 5.3 错误码 → 用户文案映射
| 业务错误码(来自 [`02_api_contract.md`](02_api_contract.md) §7 | iOS 文案 | 后续动作 |
|---|---|---|
| `QUOTA_EXCEEDED` | "本月上传额度已用完,下月 1 号重置" | 跳「我的 → 配额详情」 |
| `FILE_TOO_LARGE` | "扫描文件超过 50 MB,请尝试缩小扫描范围" | 跳「重新扫描」 |
| `INVALID_TAGS` | "标签数量超过 8 个或含非法字符" | 高亮表单标签输入框 |
| `STORAGE_FORBIDDEN` | "上传授权已过期,正在重新申请…" | 自动调 `upload-init` 重签 1 次 |
| `NETWORK_TIMEOUT`(本地判定) | "网络连接超时,已为你保存草稿" | 写入草稿表,Tab 4 可见 |
| `REDACTION_INVALID` | "脱敏信息格式错误,请重新扫描" | 跳「重新扫描」;同时 Sentry 上报(端 bug |
| `ROOM_TRANSCODE_FAILED` | "云端处理失败" | 展示「重试」按钮 → `transcode-retry`3 次失败后建议人工反馈 |
| `WORKER_UNAVAILABLE` | "服务繁忙,已自动加入队列,1 分钟后重试" | 60 s 后自动重发 `upload-complete` |
| `UNAUTHENTICATED` | "登录已过期,请重新登录" | 弹登录页(保留草稿) |
| `RATE_LIMITED` | "操作过于频繁,请稍后再试" | 30 s 冷却倒计时 |
---
## 6. Realtime 转码进度 UIiOS-X4 落地)
> ✅ 契约 iOS-X4:监听 Realtime channel `room:{room_id}` 等待 `ready` 事件,**禁止轮询** `room_versions.status`。
### 6.1 等待页 UX
进入「等待转码」页后展示一个环形进度(CircularProgressView),中间显示阶段文案,下方有「在后台等待」与「取消并保存草稿」两个按钮。
| 阶段(来自 `room_versions.status` enum) | 文案 | 环形进度估算(无真实百分比,按阶段递增) |
|------|------|--------------------------------|
| `uploading` | "上传中…" | 0 → 25% |
| `queued` | "已加入处理队列" | 25% → 35% |
| `transcoding` | "解析中 / 分层中 / 生成预览…" | 35% → 90%(每收一次 progress payload +5% |
| `ready` | "发布完成 🎉" | 100% |
| `failed` | "处理失败" | 红色 × ,展示重试按钮 |
> 阶段进度**没有真实百分比**(Worker 不上报中间百分比),iOS 端用「阶段映射 + 时间外推」假装平滑;Realtime payload 一旦到 `ready`,直接跳 100%。
### 6.2 Realtime 订阅代码骨架
```swift
let channel = supabase.realtime.channel("room:\(roomId)")
channel.on("broadcast", filter: .init(event: "transcode_progress")) { msg in
// UI
Task { await viewModel.updatePhase(msg.payload["status"] as? String) }
}
channel.on("broadcast", filter: .init(event: "ready")) { msg in
Task { await viewModel.markReady(versionId: msg.payload["version_id"] as? String) }
}
channel.on("broadcast", filter: .init(event: "failed")) { msg in
Task { await viewModel.markFailed(error: msg.payload["error"] as? String) }
}
await channel.subscribe()
```
**重连**Supabase SDK 内置 30 s 心跳 + 指数退避重连。断线期间错过的事件由 App 重连后**主动**调一次 `GET /rest/v1/room_versions?id=eq.{id}` 补查最终状态(这是唯一允许的「兜底查询」,不是轮询)。
### 6.3 后台模式与本地推送
- App 进后台时 Realtime channel 会被 iOS 暂停(WebSocket 不能在后台保活)
- 解决方案:App 进后台前若仍在等待转码 → 注册 `BGAppRefreshTask`,约 15 分钟后唤醒一次后台拉取
- 后台任务执行时调 `GET /rest/v1/room_versions?id=eq.{id}` 单次查询,若已 `ready` → 通过 `UNUserNotificationCenter` 发本地通知「你的扫描已发布」
- 不依赖服务端 APNs push(MVP 不接苹果推送证书),全部走本地通知
### 6.4 失败重试入口
- `failed` 事件到达时,等待页转为「失败页」,展示错误码对应文案 + 「重试」按钮
- 「重试」按钮调 `POST /functions/v1/transcode-retry`02_api_contract 未列,作为 iOS 期望接口提给后端)
- 失败 3 次后,「重试」按钮隐藏,改显「联系客服」入口(跳邮件 `mailto:`
---
## 7. 扫描引导 UX 与质量门控
> 复用 [`plans/iphone/iphone_simplified_plan.md`](../iphone/iphone_simplified_plan.md) §3.1 的「准备 / 慢速移动 / 覆盖检查」三阶段思想,但裁剪为消费级三步引导。精度边界引用 [`plans/iphone/roomplan_accuracy_and_cad_export.md`](../iphone/roomplan_accuracy_and_cad_export.md) §1.2 的 ±3 cm 墙面 / ±5 cm 家具。
### 7.1 三步引导
| 步骤 | 标题 | 文案与示意 | 用户操作 |
|------|------|----------|---------|
| **Step 1 · 准备** | "把房间整理一下" | 三条建议:① 打开所有灯 ② 移走移动物体(宠物/人) ③ 清洁 LiDAR 镜头 | 点「我准备好了」 |
| **Step 2 · 慢走** | "用 0.3 m/s 的速度环绕房间" | 短视频示意 + RoomCaptureView 自带的语音引导一起播 | 进入 `RoomCaptureView`,扫描 510 分钟 |
| **Step 3 · 完成** | "看一下你的房间" | 展示 RoomPlan 生成的 wireframe 预览 + 质量分 | 「重扫」或「下一步:脱敏并上传」 |
### 7.2 实时质量提示(扫描中浮层)
`RoomCaptureView` 上叠加一个 SwiftUI 浮层,每 1 s 从 `RoomCaptureSession.Delegate` 读取一次状态:
| 信号 | 检测方式 | 浮层提示 |
|------|---------|---------|
| **覆盖率不足** | RoomPlan `instructions == .lowTexture` 或墙面置信度 `.low` 的占比 > 30% | "🔍 这面墙再扫一遍" |
| **漏扫墙面** | 房间未闭合(拓扑检测:墙数 < 3 或存在自由端) | "↩️ 似乎少了一面墙" |
| **强反光面** | 检测到 `Window``Mirror`(用 RoomPlan category)正对镜头 | "⚠️ 镜面会影响精度,请侧 30° 扫描" |
| **速度过快** | ARKit transform 一阶差分 > 0.6 m/s 持续 2 s | "🐢 慢一点,0.3 m/s 最佳" |
### 7.3 完成后质量分(A/B/C
扫描结束后用一个简单规则给房间打分(**不是机器学习模型**,是规则引擎,避免 MVP 复杂度):
| 维度 | A 档 | B 档 | C 档 |
|------|------|------|------|
| 墙面 confidence high 占比 | ≥ 90% | 7090% | < 70% |
| 家具识别数 | ≥ 5 件 | 24 件 | < 2 件 |
| 房间闭合 | ✅ | ✅ | ❌ 拓扑不闭合 |
| 扫描时长 | 510 min | 35 min | < 3 min |
**任一维度命中 C → 整体 C 档**
- **A/B 档**:直接进上传流,UI 给绿勾或黄勾标记
- **C 档**:弹窗「这次扫描质量较低,建议重扫;如仍上传,我们会给它打『C 档』标签,发现页排序权重会降低」——**仍允许上传**(社区数据不挑食),但 `rooms.quality_grade` 字段标 C[`02_api_contract.md`](02_api_contract.md) E-06 列表查询时排序权重 × 0.5
---
## 8. 隐私与权限请求文案
### 8.1 `Info.plist` 条目
| Key | 中文 usage description |
|-----|----------------------|
| `NSCameraUsageDescription` | "CrowdRoom 需要相机权限来扫描你的房间。我们只会使用相机进行 3D 建模,不会单独保存照片到相册。" |
| `NSPhotoLibraryAddUsageDescription` | "CrowdRoom 需要相册写入权限,以便把你扫描完成的 3D 房间预览图保存到相册(可选)。" |
| `NSLocationWhenInUseUsageDescription` | **v0.2 文案**"CrowdRoom 可选使用你的位置,仅为给你扫描的房间打上「城市」级标签(约 5 km 精度),**该城市标签会显示在你的公开房间页**;**你的精确 GPS 坐标不会上传、不会存储到服务器**。你可以随时在「设置 → 隐私 → 位置打标」里关闭。" |
| `NSMicrophoneUsageDescription` | **不申请**RoomPlan 不需要录音) |
| `NSUserTrackingUsageDescription` | **不申请**(MVP 不做跨 App 跟踪,见 8.2 |
> 🔄 **v0.2 — 回写自 G-5**:本节根据 [`09_privacy.md`](09_privacy.md) §4 P-3 决策追加。`NSLocationWhenInUseUsageDescription` 文案在 v0.2 起必须显式包含「**城市标签会显示在公开房间页**」与「**精确 GPS 不会上传**」两句——前者满足 PR-3 用户可控的「授权时即告知公开后果」,后者满足 PR-2 最小数据采集的「告知不持有原始坐标」。该文案变更同时对齐 [`01_data_schema.md`](01_data_schema.md) §3.2 `rooms.location_city`= `location_label` 别名)字段语义与 [`04_web_app_plan.md`](04_web_app_plan.md) §8.2 详情页元信息行。
### 8.2 ATTApp Tracking Transparency
**结论:MVP 不申请 ATT**
- CrowdRoom MVP 不接广告 SDK、不与第三方数据公司共享 IDFA
- Sentry / MetricKit 不使用 IDFA,走 Apple 私有指标
- 后续如接入 Apple Search Ads 归因,再单独申请 ATT 并补充权限弹窗文案
- 这一决策与本任务规划的「隐私治理」预留接口由子任务 5 收口
### 8.3 首次启动权限弹窗顺序
1. 进入扫描页 → 申请 `Camera`(必要)
2. 扫描完成后想保存预览图到相册 → 申请 `PhotoLibraryAdd`(按需)
3. 上传表单页填写「城市」标签时 → 申请 `LocationWhenInUse`(按需,可跳过)
> ✅ 契约 iOS-X5 衍生:进入扫描页时同时调 `GET /functions/v1/quota`,与相机权限请求**并行**发起,弹窗与配额预检不互相阻塞。
### 8.4 PrivacyManifest`PrivacyInfo.xcprivacy`
> 🔄 **v0.2 — 回写自 G-4**:本节根据 [`09_privacy.md`](09_privacy.md) §6.2 C-P2-6Apple 2024 春起强制,本已 cross-ref 为「MVP 待补」)新增。Apple App Store Connect 在 Xcode 15+ 上传时会自动跑 **Privacy Manifest Aggregate Report**;缺失或第三方 SDK 未声明会**直接拒审**。
#### 8.4.1 我们自己声明(CrowdRoom App bundle 内)
`PrivacyInfo.xcprivacy` 必须列出 4 类 Required Reason API 使用类别(CrowdRoom 业务场景下能触发的常见类别):
| `NSPrivacyAccessedAPICategory` 键 | 触发场景 | 选择的 reason code |
|---|---|---|
| `NSPrivacyAccessedAPICategoryFileTimestamp` | 读 `.usdz` / `.roomplan.json` 文件 `creationDate` 用于上传顺序排序 | `C617.1`(在 App 内用于显示给用户) |
| `NSPrivacyAccessedAPICategorySystemBootTime` | MetricKit / Sentry 性能事件相对时间戳 | `35F9.1`(计算相对设备启动时间) |
| `NSPrivacyAccessedAPICategoryDiskSpace` | 扫描前预估「设备剩余空间是否够暂存原始 .usdz」 | `85F4.1`(在 App 内显示空间不足提示) |
| `NSPrivacyAccessedAPICategoryUserDefaults` | 持久化「位置打标开关 / 端侧脱敏统计」等用户偏好 | `CA92.1`(同一 App 内读写) |
同时声明 **数据收集分类**`NSPrivacyCollectedDataTypes`),与 App Store Connect Privacy Nutrition Label 同源:
| 数据类型 | 是否收集 | 用途 | 关联用户 |
|---|---|---|---|
| `NSPrivacyCollectedDataTypeUserID` | ✅ | `App Functionality`(鉴权) | 关联 |
| `NSPrivacyCollectedDataTypeEmailAddress` | ✅ | `App Functionality`(账号恢复) | 关联 |
| `NSPrivacyCollectedDataTypeOtherUserContent`(房间几何 + 标题 + 评论) | ✅ | `App Functionality` | 关联 |
| `NSPrivacyCollectedDataTypeCoarseLocation`(城市级 5 km) | ✅(可选) | `App Functionality`(公开标签) | 关联 |
| `NSPrivacyCollectedDataTypePreciseLocation` | ❌ | — | — |
| `NSPrivacyCollectedDataTypeCrashData` | ✅ | `App Functionality`Sentry | 不关联 |
| `NSPrivacyCollectedDataTypePerformanceData` | ✅ | `Analytics`PostHog,需 opt-in | 不关联 |
| `NSPrivacyCollectedDataTypeAdvertisingData` | ❌ | — | — |
| `NSPrivacyCollectedDataTypeTrackingID`IDFA | ❌ | — | — |
#### 8.4.2 第三方 SDK PrivacyManifest 自查表
每个引入的 SDK 都必须**自带** `PrivacyInfo.xcprivacy`(Apple 维护一份强制 SDK 名单:`OpenSSL / FMDB / SQLite` 等通用基础库以及主流分析/广告 SDK 都在其中)。MVP 引入的 SDK 自查状态:
| SDK | 是否在 Apple 强制列表 | SDK 已自带 PrivacyManifest | 版本下限 | 备注 |
|---|---|---|---|---|
| **Supabase Swift**GoTrue + PostgREST + Storage + Realtime | ⚠ 部分依赖(如 SQLite)在列 | ✅ 自 v2.0+ 已自带;底层 `URLSession` 无需 | v2.5.0+ | Apple 通用网络栈无需自带 |
| **Sentry-Cocoa** | ✅ 在列 | ✅ 自 v8.20+ 自带 | v8.25+ | 关键:必须升级到 ≥ v8.20 才能过审 |
| **PostHog iOS** | ✅ 在列(涉及 `UserDefaults` + `SystemBootTime` | ✅ 自 v3.0+ 自带 | v3.5+ | MVP 仅在用户 opt-in 后初始化 |
| **Lucide Icons**(仅 SVG 资源,无 runtime | ❌ 不在列 | n/a | n/a | 资源包,不申报 |
| **Apple Vision / RoomPlan / ARKit** | n/a | n/a(系统框架) | iOS 17+ | 系统框架不计入第三方 |
> **CI 检查项**:在 GitHub Actions 上跑 `xcodebuild` 时附加 `-checkPrivacyManifest YES`Xcode 15.3+ 支持的隐式 lint),任何缺失会直接 build fail。
#### 8.4.3 上传到 App Store Connect 时的签名验证清单
按以下清单逐项勾选,否则放弃发版:
1. ☐ Xcode 项目根目录下存在 `PrivacyInfo.xcprivacy`(不是 `Resources/` 子目录)
2. ☐ Archive 后 `.ipa` 解包,`PrivacyInfo.xcprivacy` 出现在主 bundle 根
3. ☐ 所有 `Frameworks/` 下的第三方 `.framework` / `.xcframework` 内部存在 `PrivacyInfo.xcprivacy`
4. ☐ App Store Connect 上传后 24h 内查看 **Privacy Manifest Aggregate Report**,确认无 `Missing reason code``Missing data type` 警告
5. ☐ 如有警告 → 找到对应 SDK 升级版本 → 重新 archive 上传
6. ☐ 与 App Store Connect 「应用隐私详情」(Privacy Nutrition Label)字段逐项核对(同源;不一致会被 Apple 人工标红)
> **拒审风险等级**:高。Apple 2024 春起对漏报的 PrivacyManifest 直接拒审(不再警告);本节的 6 项 checklist 必须在每次 minor 版发版前由 release manager 复核一次。
---
## 9. MVP 范围与不做项
### 9.1 MVP(与 [`00_overview.md`](00_overview.md) §7.1 同步,8 周窗口)能交付
| 模块 | 交付物 |
|------|--------|
| Auth | Sign in with Apple + 邮箱登录两条路径 |
| Tab 1 发现 | 公共 feed 分页 + 标签筛选 + 搜索 |
| Tab 2 通知 | 评论 / 点赞 / Remix 三类通知 + 转码完成通知 |
| Tab 3 扫描 | RoomPlan 标准 `RoomCaptureView` + 三步引导 + 质量分 + 端侧脱敏 + 三段式上传 |
| Tab 4 我的房间 | 已发布 / 草稿 / 失败 三个分段控制器 |
| Tab 5 我的 | Profile、配额详情、设置、隐私脱敏策略说明 |
| 详情页 | 静态 360° 缩略图轮播 + 评论 + 点赞 + 跳 Web Remix |
| Realtime | `room:{id}``user:{id}` 两个 channel 订阅 |
| 配额 | 进扫描前预检 + 错误码本地拦截 |
### 9.2 明确不做
-**iOS 端 Remix 编辑器**(统一在 Web 端做,原因:R3F + drei 在 iOS WKWebView 性能足够;做原生 SceneKit Remix 工作量约等于重写一遍 Web 渲染器)
-**AR 内即时换家具预览**(QuickLook 也不接,避免「我以为我换了,实际服务端没收到」的双状态问题)
-**协同编辑**Remix 走 fork 模型)
-**Android / iPad 专属布局**iPad 用 iPhone scaledMVP 不做 split view 适配)
-**Apple Watch / visionOS 端**
-**本地 .glb 渲染**(详情页只展示缩略图,3D 渲染交给 Web)
-**离线模式**(草稿可保留,但浏览必须联网)
-**自训 ML 模型做家具识别**(一律走 RoomPlan 内置 16 类语义)
---
## 10. 风险与开放问题
| # | 风险 / 开放问题 | 当前判断 | 待解答 |
|---|---------------|---------|--------|
| **R-iOS-1** | **RoomPlan 在低光场景失败率高**[`roomplan_accuracy_and_cad_export.md`](../iphone/roomplan_accuracy_and_cad_export.md) §1.3 提到光照影响 ±1–3 cm,且极弱光会直接拒绝开始扫描) | UX 兜底:扫描前自动检测 `AVCaptureDevice.iso`,过高时弹窗建议开灯;仍允许强行扫描 | 是否需要在 App 内置「补光手电」开关?(受发热限制,可能不实用) |
| **R-iOS-2** | **端侧脱敏在 iPhone 12 Pro 上耗时超目标**(§4.3 估算 16 s,超 8 s 预算 2 倍) | 12 Pro 是 RoomPlan 最低支持设备(无 12 Pro 就无 LiDAR),不能放弃;预计提示「正在处理,预计 15 秒」即可 | 是否对 12/12 Pro 默认降级到「仅检测 + 不模糊,把任务转交服务端」?——但这违反 iOS-X1,需要子任务 5 隐私治理章节回答能否破例 |
| **R-iOS-3** | **`.usdz` 重新打包工具链稳定性**:iOS 沙箱无法跑 `usdzconvert` CLI,只能用 `ModelIO` + 手动 zip`ModelIO` 对 USD 写回支持有限 | 备选方案:不重打包,把脱敏后的贴图作为「平行文件」一起 PUT,让 Worker 端做替换合并。代价是 Worker 改造一次 | 是否可接受 Worker 帮忙合并?此决策由子任务 5 与后端方共同确认 |
| **R-iOS-4** | **Universal Link 在国内 Safari 跳转受限**:部分国产浏览器(QQ/微信)不会触发 UL | iOS 端从微信打开链接时,引导用户「点右上角 → 在 Safari 中打开」;这是行业通病不再投入解决 | 是否做微信小程序版的 Remix 编辑入口?P2 再说 |
| **R-iOS-5** | **Realtime channel 在长时间后台时丢事件**WebSocket 在 iOS 后台 30 s 内被杀;BGAppRefreshTask 调度由系统决定,最长可能 1 小时才唤醒一次 | 兜底:每次 App 前台化时主动调一次 `GET /rest/v1/room_versions?id=in.(...)` 补查所有「进行中」版本 | 此「补查」是否会被风控误判为「轮询」?需要与 iOS-X4 契约文字微调(明确:「补查」≠「轮询」) |
---
## 11. 给子任务 5(隐私治理)的契约要点
iOS 端在端侧脱敏管线(§4)、权限文案(§8)、风险 R-iOS-2/R-iOS-3 处与隐私治理章节有强耦合。子任务 5 撰写隐私治理总章时**必须保证**以下要点:
| # | 治理章节必须保证 X | 与本章对应 |
|---|------------------|-----------|
| **P-1** | 明确「端侧脱敏失败 3 次后是否可降级到服务端二次脱敏」的策略——若不允许,则 iOS-X1 维持硬约束;若允许,则需定义服务端二次脱敏 SLA | §4.4 + R-iOS-2 |
| **P-2** | 给出 `redactions[]` 表的保留期与可见性策略(用户能否查看自己上传的人脸 bbox 数据) | §4.1 末尾的 redactions POST |
| **P-3** | 明确「位置标签是否在公开页面展示」——若展示,则需补强 `NSLocationWhenInUseUsageDescription` 文案中的「会被其他用户看到」声明 | §8.1 location 文案 |
| **P-4** | 给出用户「申请删除我所有数据」的端到端流程,iOS 端需要提供入口(设置 → 账号 → 注销账号并删除全部数据) | §1.2 SettingsPage 待加子页 |
| **P-5** | 明确「ATT 何时启用」的触发条件(接入哪种 SDK 必须申请 ATT),iOS 端据此决定是否在某个版本灰度推 ATT 弹窗 | §8.2 |
| **P-6** | 明确「未成年用户保护」策略:是否需要在 iOS 端首次启动时弹年龄确认;CrowdRoom 内容是否在 App Store 标 17+ | 本章未覆盖,留给子任务 5 |
---
## 12. 本章小结
| 关键产出 | 一句话 |
|----------|--------|
| **5 Tab + 22 页面 IA** | 扫描放中间凸起按钮强化采集动作;Remix 编辑全在 Web,iOS 只做发起入口 |
| **3 个核心 Flow** | A 扫描脱敏上传等待发布 / B 浏览跳 Web Remix / C 通知跳详情 |
| **13 项技术栈选型** | SwiftUI + iOS 17 + RoomCaptureView + Vision + CoreImage + supabase-swift + SwiftData |
| **端侧脱敏管线** | RoomPlan 导出 → ModelIO 解包 → Vision 检测 → CIGaussianBlur 蒙版合成 → 重打包,iPhone 15 Pro ≤ 8 s |
| **三段式上传 + 10 条错误码文案** | upload-init → 直传 Storage → upload-complete,背景 URLSession + 断点续传 + 分片 + 电量检查 |
| **Realtime 5 阶段进度 UI** | uploading → queued → transcoding → ready/failed,禁止轮询,后台用 BGAppRefreshTask 兜底 |
| **三步扫描引导 + A/B/C 质量分** | 复用 iPhone 简易方案的扫描节奏,规则引擎打分,C 档允许上传但排序降权 |
| **6 条治理契约移交** | 端侧脱敏破例、redactions 保留期、位置可见性、删除流程、ATT、未成年保护 |
读完本章你应能:
- ✅ 给 iOS 工程师一份 8 周内可交付的功能清单
- ✅ 评审端侧脱敏方案是否真的能落到 iPhone 12 Pro 上
- ✅ 接手子任务 5 时知道隐私治理章节要回答哪 6 个问题
---
**章节版本**v0.1 · 草案
**关键收获**iOS 端是 CrowdRoom 的**唯一采集入口**与**最重的隐私防线**——5 条硬契约(iOS-X1~X5)全部落到具体章节;3D 渲染、Remix 编辑、转码、审核一概甩给 Web 与服务端,保持 iOS 端「轻、快、合规」三件套。
+884
View File
@@ -0,0 +1,884 @@
# CrowdRoom · Web 端设计(v0.2
> **版本**v0.22026-05-19
> **v0.2 修订**:回写 G-6(§1.1 路由表追加 `R-16 /me/embeds` 用户管理 iframe 嵌入配额与 Referer 白名单页)+ G-7(§1.1 路由表追加 `R-17 /admin/reports` 内部审核工作台,role=admin only)。源决策见 [`10_governance.md`](10_governance.md) §3.1reviewer 工具)与 §4 P-W-6iframe 限流落地)。
> 本章承接 [`00_overview.md`](00_overview.md) §4 架构图、[`01_data_schema.md`](01_data_schema.md) 的 9 张表与 `layer_manifest.json` Schema、[`02_api_contract.md`](02_api_contract.md) §2 的 19 个端点(v0.2,原 16 + 3 个新增 E-17/18/19)与 §8.2 的 6 条 Web 硬契约(Web-Y1 ~ Web-Y6)、以及 [`03_ios_app_plan.md`](03_ios_app_plan.md) §2.2 Flow B 的 Universal Link 入口,落地为一份**可直接交付给 Web 工程团队**的设计。
>
> **Web 端的使命**:把"扫房 → 上传"产出的 `canonical.glb + layer_manifest.json` 在浏览器里渲染成可玩、可分层、可换材质、可换家具、可 Remix、可分享的 3D 房间作品;这是 CrowdRoom 直接回应用户原始需求"**类 ArcGIS 分层 + 显示/隐藏 + 换材质 + 换其他家具**"的入口。
>
> 本章不重复隐私治理总章(由子任务 5 收口);本章亦不涉及 iOS 端 RoomPlan 采集、转码 Worker 实现(已在 03/02 中定义)。
---
## 1. Web 站点信息架构
### 1.1 路由表(Next.js App Router
CrowdRoom Web 选用 [Next.js 14 App Router](https://nextjs.org/docs/app),因为 SSR + OG 卡片 + SEO + Edge Runtime 都是消费级社区的硬需求。路由规划如下(≥10 条):
| # | 路由 | 渲染 | 主要数据 | 说明 |
|---|------|------|---------|------|
| R-01 | `/` | SSR + ISR (60s) | `E-06 GET rooms` 公开 feed 前 20 条 | 首页瀑布流,落地页 |
| R-02 | `/r/[room_id]` | SSROG meta 必须服务端拼) | `E-07 GET rooms + current_version` | 房间详情 3D 浏览器 |
| R-03 | `/r/[room_id]/edit?fork=1` | CSR only(编辑器太重) | `E-09` 父 manifest + 父 glb + 空 overlay | **Remix 编辑器**(核心) |
| R-04 | `/remix/[remix_id]` | SSR | `remixes` 行 + overlay JSON | 已发布的 remix 详情;引用父几何 |
| R-05 | `/remix/[remix_id]/edit` | CSR | 自己创建的 remix 才可进 | Remix 二次编辑(owner-only |
| R-06 | `/u/[username]` | SSR + ISR (120s) | `users` + 该用户的 rooms / remixes | 用户主页 |
| R-07 | `/search?q=...&tag=...&grade=...` | SSR | `E-08 search_rooms` RPC | 搜索结果页 |
| R-08 | `/assets?kind=material&class=wood` | SSR + ISR (300s) | `assets` 表过滤 | 公共资产库浏览(独立可逛) |
| R-09 | `/login` / `/signup` | CSRSupabase Auth | `auth.users` | 登录/注册;OAuth 回调 `/auth/callback` |
| R-10 | `/me` / `/me/rooms` / `/me/drafts` / `/me/quota` | CSR(需登录) | `E-15 quota`、个人 rooms | 个人控制台 |
| R-11 | `/notifications` | CSR + Realtime channel `user:{id}` | 评论 / 点赞 / Remix / 转码完成事件流 | 通知中心 |
| R-12 | `/embed/r/[room_id]` | CSR(极简 chrome | 同 R-02 | iframe 嵌入版(无导航条、无评论) |
| R-13 | `/about` / `/legal` / `/privacy` / `/terms` | SSG | 静态 MDX | 法务与说明,由子任务 5 写正文 |
| R-14 | `/api/og/r/[room_id]` | Edge FunctionVercel OG | manifest + viewState | **OG 卡片动态生成**,详见 §9 |
| R-15 | `/sitemap.xml` / `/robots.txt` | SSG | 公开 rooms 列表分页 | SEO 入口 |
| R-16 | `/me/embeds` | CSR(需登录) | `embed_settings` 表 + Referer 限流统计([`10_governance.md`](10_governance.md) §4 P-W-6 | **v0.2 / G-6**:用户管理「自家房间被 iframe 嵌入」的配额、Referer 白名单与黑名单;显示日访问量、可一键关闭嵌入或封禁某 Referer |
| R-17 | `/admin/reports` | CSR**role=admin** 才可进,否则 403 | `reports` 工单队列 + NSFW score + 敏感词命中 + 内容预览 | **v0.2 / G-7**:内部审核工作台(reviewer 用),对应 [`10_governance.md`](10_governance.md) §3.1 「兼职 Reviewer × 1」工具需求;MVP 用 Supabase Studio + 本路由组合 |
> 共 **17 条路由组**v0.2,原 15 + v0.2 / G-6 新增 R-16 + v0.2 / G-7 新增 R-17),覆盖 5 大场景:浏览(R-01/02/04/07/08)、创作(R-03/05/10)、社交(R-06/11)、基础设施(R-09/12/13/14/15)、**v0.2 治理 / 用户控制(R-16/17**。
> 🔄 **v0.2 — 回写自 G-6**R-16 `/me/embeds` 是 [`10_governance.md`](10_governance.md) §4 P-W-6「iframe 嵌入频次/速率限制」决策的用户侧落地点。页面内容:
>
> - **嵌入开关**(默认开 / 单房间粒度可关,关闭后 R-12 `/embed/r/{id}` 返回 403 + 业务码 `EMBED_FORBIDDEN`,详见 [`02_api_contract.md`](02_api_contract.md) §7
> - **Referer 列表**:所有曾经成功嵌入过自家房间的外部域名 + 日访问量 + 累计访问量;按访问量降序,最多展示前 100 条
> - **白名单登记**:用户可主动登记某个 Referer 域名进入「注册 Referer」档(30 req/min/Referer),未登记的走默认档(5 req/min/Referer),对应 P-W-6 表格的两档
> - **黑名单封禁**:点击某条 Referer → 「封禁此来源」→ 写入 `embed_blocked_referers` 表(service_role 维护)→ 该 Referer 立即收到 `EMBED_FORBIDDEN`
> - **配额展示**:当前账户档位(free 1 万 / creator 10 万 / pro 100 万 req/月)+ 已用 / 剩余 / 重置时间
> - **不计入 Storage 流量**:页面顶部固定文案「iframe 嵌入流量由平台兜底,不消耗你的 Storage 配额」(与 [`10_governance.md`](10_governance.md) §4 P-W-6「CDN 流量归属」条款对齐)
>
> 该路由在 §1.2 跳转图中归属 `/me/*` 子树,鉴权同 R-10。
>
> 🔄 **v0.2 — 回写自 G-7**R-17 `/admin/reports` 是 [`10_governance.md`](10_governance.md) §3.1「兼职 Reviewer × 1,每日 2 小时(约工单 30–50 条 / 日)」的工具承载页。页面内容:
>
> - **鉴权门控**:进入页面前 `middleware.ts` 读 `auth.users.app_metadata->>role` 判断是否 `'admin'`;非 admin 直接返回 403(不是 401,避免暴露路由存在)
> - **工单队列**:表格列 `report_id / target_type / target_id / reason / 自动信号(NSFW score、敏感词命中)/ 举报数累计 / SLA 剩余时间 / 状态`
> - **内容预览**:点击某行 → 右侧抽屉打开目标内容(房间 3D 预览 / 评论 / 用户档案)+ 既往违规记录
> - **判定按钮**`Approve(误报恢复)/ Reject(违规下架,选择处罚等级 L1-L4 或 WL 白名单越级)/ Defer(转 owner-team 终审)`,对应 [`10_governance.md`](10_governance.md) §6 处罚阶梯
> - **批量操作**:选中多行 → 批量 Approve / Reject(限同一 target_type
> - **审计落库**:每个判定写 `moderation_actions` 表(含 reviewer_id / 操作 / 时间 / 理由),用于 §8 申诉流程二次复核
> - **MVP 数据源**:直接 PostgREST 查 `reports` 表 + `comments / rooms / remixes` 关联;P1 起接 Hive Moderation 第三方审核分数
>
> 该路由**不**在 `/sitemap.xml` 也**不**在 `/robots.txt` 允许列表(默认 noindex,避免 SEO 误抓)。
### 1.2 核心页面跳转图(用户旅程)
```mermaid
graph LR
Home[Home /]
Search[Search /search]
Detail[RoomDetail /r/room_id]
RemixEdit[RemixEditor /r/room_id/edit fork=1]
RemixDetail[RemixDetail /remix/remix_id]
Assets[AssetLibrary /assets]
UserHome[UserHome /u/username]
Login[Login /login]
Me[Profile /me]
Embed[Embed /embed/r/room_id]
Home --> Detail
Home --> Search
Search --> Detail
Detail --> RemixEdit
Detail --> RemixDetail
Detail --> UserHome
Detail --> Embed
RemixEdit --> RemixDetail
RemixDetail --> RemixEdit
UserHome --> Detail
Home --> Assets
RemixEdit --> Assets
Login --> Me
Me --> Detail
```
> Remix 编辑器(`RemixEdit`)是整个 Web 的"重心页面"——所有"换材质/换家具/分层切换"的交互都在这里发生,对应用户原始需求。
---
## 2. 技术栈选型表
| 模块 | 推荐 | 备选 | 一行理由 |
|------|------|------|---------|
| Web 框架 | **Next.js 14 App Router + TypeScript** | Remix / SvelteKit | SSR + OG meta + Edge Function 一栈搞定;社区生态最厚 |
| 3D 渲染 | **Three.js r160+ · React-Three-Fiber v8 · `@react-three/drei`** | Babylon.js / PlayCanvas | R3F 让"图层切换/换家具"用 React 组件思维直接表达;与 Next.js SSR 兼容(动态 import + `ssr:false` |
| 模型加载 | **drei `useGLTF` + `KTX2Loader` + `MeshoptDecoder`** | three.js 原生 `GLTFLoader` | drei 内置缓存与 Suspense 集成;KTX2/Meshopt 都是 [`02_api_contract.md`](02_api_contract.md) §3.2 转码管线产物 |
| 状态管理 | **Zustand 4.x**(图层/相机/选中态/草稿) | Jotai / Redux Toolkit | Zustand 单 store + `subscribeWithSelector` 对 R3F 性能友好;不引入 Provider 树 |
| 服务端数据 | **`@supabase/ssr`**Server Component + Route Handler | `@supabase/auth-helpers-nextjs`(已废弃) | App Router 官方推荐;cookie 鉴权链路安全 |
| 浏览器数据 | **`@supabase/supabase-js` v2 + `@tanstack/react-query` v5** | SWR | React Query 的乐观更新 + 缓存失效控制对评论/点赞场景最合适 |
| 样式 | **Tailwind CSS v3 + shadcn/uiRadix UI 二次封装)** | CSS Modules / Stitches | shadcn 的 Dialog/DropdownMenu/Slider 直接拿来即用,A11y 已经做掉 |
| 图标 | **Lucide React** | Heroicons / Tabler | 与 shadcn 默认同款;树摇彻底 |
| 表单 | **React Hook Form + Zod** | Formik | Zod schema 可同时复用到 Edge Function 的入参校验 |
| 国际化 | **next-intl**(中英双语,`zh-CN` / `en-US` | next-i18next | App Router 友好;按路由段 `/[locale]/...` 切分 |
| 分析 | **PostHog Cloud**(自托管事件 + Session Replay 关闭以保护隐私) | Plausible | 不用 GA(合规风险 + 国内访问差);PostHog 提供 funnel 与 feature flag |
| 部署 | **Vercel**Edge Network + Image Optimization | Cloudflare Pages + Workers | Vercel 与 Next.js 14 集成最深;CN 访问后期可加 Cloudflare 镜像 |
| 错误监控 | **Sentry Browser SDK** | Datadog RUM | 与 Supabase 后端 / iOS 端共用一个 Sentry 项目,跨端联查 |
| 包管理 | **pnpm 8** + Turborepo(单仓多包) | npm / yarn | 与 iOS Worker 共享 schemas/ 包;pnpm 节省磁盘 |
| 测试 | **Vitest + Playwright** | Jest + Cypress | Vitest 与 Vite/Next 14 同栈;Playwright 跑 3D 截图 diff |
---
## 3. 3D 渲染架构
### 3.1 渲染层链路(从 URL 到画面)
> ✅ **契约 Web-Y1**:渲染入口**必须**先拉 `layer_manifest.json`,按 4 层固定 ID`walls / floor / furniture / materials`)切换可见性;**禁止**自己解析 .glb 节点树推断结构——见 [`02_api_contract.md`](02_api_contract.md) §8.2 Y1。
```mermaid
graph LR
URL[URL r room_id] --> Route[App Router]
Route --> Fetch1[CDN GET layer_manifest.json]
Route --> Fetch2[CDN GET canonical.glb]
Fetch1 --> Validate[Zod schema check 失败抛 MANIFEST_INVALID]
Validate --> BuildMap[构建 Map layerId nodeIds]
Fetch2 --> Cache[useGLTF 缓存]
BuildMap --> Scene[R3F Canvas Scene]
Cache --> Scene
Scene --> Groups[4 个 group layerRef]
Groups --> WallGroup[group walls]
Groups --> FloorGroup[group floor]
Groups --> FurnGroup[group furniture]
Groups --> MatSlots[material slots 注入到上述三层 mesh]
State[Zustand layerStore] --> Bind[group.visible 双向绑定]
Bind --> WallGroup
Bind --> FloorGroup
Bind --> FurnGroup
```
### 3.2 R3F 场景图组织原则
| 决策 | 拍板 | 理由 |
|------|------|------|
| 每层一个 `<group>` 而非用 mesh.visible 逐个 | **是** | 切层 = 1 次 React state 变更触发 1 次 group.visible 赋值;逐 mesh 切要 N 次,浪费 |
| 节点名约定 | **沿用 manifest 中 `mesh_node_ids[]`Worker 端已统一 `wall_* / floor_* / furn_*` 前缀** | Web 端通过 `scene.getObjectByName(nodeId)` O(1) 拿引用 |
| `<Suspense>` 边界 | **Canvas 内一层、AssetPicker 缩略图一层** | 渲染主场景与挑材质的网络等待互不阻塞 |
| 选中态高亮 | **额外注入 `<Outline>` (drei) post-processing,不修改 mesh material** | 防止"选中后退出忘了恢复"的副作用 |
| 物理 / 灯光 | **MVP 用 `<Environment preset="apartment">`,无物理引擎** | 真实光照成本不划算;apartment 预设对家居场景视觉够用 |
### 3.3 性能预算
| 设备档 | 帧率目标 | `.glb` 大小 | 三角形 | Draw Call | 纹理上限 |
|--------|---------|-------------|--------|-----------|---------|
| 桌面端(Chrome/Edge/Firefox | **≥ 60 fps** | ≤ 5 MB | ≤ 200 k | ≤ 30 | ≤ 16 张 1024² |
| 移动端 Safari iOS 16+ | **≥ 30 fps** | ≤ 2 MB | ≤ 80 k | ≤ 15 | ≤ 8 张 512² |
| 旧桌面(Intel 集显) | ≥ 30 fps | 同移动端预算 | 同上 | 同上 | 同上 |
**预算违反时的兜底**(在 `<Canvas>` 外部检测 `gpu.tier`,参考 `@react-three/drei``useDetectGPU`):
- Tier 1(低端)→ 自动启用 `dpr={[1, 1]}` + 关闭阴影 + Texture 自动降到 512²
- Tier 0(无 WebGL2)→ 退化到 `model-viewer` 静态预览组件(详情页给"3D 视图不可用"提示)
### 3.4 LOD 策略
**MVP 不做 LOD**(理由:单房间几何已经在 Worker 端走 Draco/Meshopt 压缩到 15 MB,移动端不掉帧;额外切多套 LOD 会让转码 Worker 跑得更慢)。
**何时引入**
- 单房间 `canonical.glb > 8 MB`(超过 creator 配额上限)
- 单页面同屏需展示 ≥ 2 个房间(如对比页 / 楼层拼接 P2)
- 移动端 90 分位首屏 TTI > 4 s
引入时方案:Worker 端额外产 `canonical_lod1.glb`30% 面数)与 `canonical_lod2.glb`10% 面数),manifest 增加 `lod_uris[]` 字段,Web 端按相机距离切换。
### 3.5 相机控制
| 场景 | 控制器 | 行为 |
|------|--------|------|
| 进入房间 | **OrbitControls + 自动 fit-to-bbox**(从 manifest `room_metrics` 反推 OBB | 1.2 s ease-out 缓动到房间斜上方 45° |
| 点击图层节点 | OrbitControls.target 飞到该 mesh OBB 中心 | 0.6 s 缓动 + 自动调整距离使物体撑满 60% 视口 |
| 全屏 / 嵌入 (`/embed`) | 同上,但移除右键面板 | 嵌入版给最简 UI |
| WebXRVR / AR | **MVP 不做**,预留 `<XR>` 组件挂载点 | drei `@react-three/xr` 已就绪 |
---
## 4. 类 ArcGIS 分层 UI 设计 🌟
> **本节直接回应用户原始需求"空间信息,类似 ArcGIS 的分层地图信息一样,选择显示、隐藏"。** 这是 Web 端最具辨识度的体验,必须做精。
### 4.1 图层面板(LayerPanel)整体布局
房间详情页的右侧抽屉(Desktop ≥ 1280 时常驻 320 px;移动端折叠为底部 sheet)展示**4 层固定结构**,与 [`01_data_schema.md`](01_data_schema.md) §5.1 的"4 层固定"约束严格对齐:
```
┌─────────────────────────────────┐
│ Layers [ ⊕ ] │ ← 顶部 "保存为视图" 按钮
├─────────────────────────────────┤
│ 👁 🔒 [████████░░] Walls ▾ │ ← 可见性 / 锁定 / 不透明度 / 展开
│ └─ wall_0 [缩略] 👁 │
│ └─ wall_1 [缩略] 👁 │
│ └─ wall_2 [缩略] 👁 │
├─────────────────────────────────┤
│ 👁 🔒 [████████░░] Floor ▸ │
├─────────────────────────────────┤
│ 👁 🔒 [██████░░░░] Furniture ▾ │
│ └─ 🛏 Bed [缩略] 👁 │ ← 家具层显示语义图标
│ └─ 🛋 Sofa [缩略] 👁 │
│ └─ 📺 TV [缩略] 👁 │
├─────────────────────────────────┤
│ 🎨 Materials ▾ │ ← 材质层独立形态(非几何)
│ └─ mat_wall_paint [色块] │
│ └─ mat_floor_wood [贴图] │
│ └─ mat_bed_fabric [色块] │
└─────────────────────────────────┘
```
### 4.2 每层提供的操作
| 操作 | 图标 | 行为 | 状态键(Zustand |
|------|------|------|------------------|
| **可见性 toggle** | 👁 / 🚫 | 整层 `group.visible` 切换 → 直接对应"显示/隐藏"用户需求 | `layers[kind].visible: bool` |
| **锁定** | 🔒 / 🔓 | 编辑模式下防止误操作;锁定后该层节点不响应点击/拖拽 | `layers[kind].locked: bool` |
| **不透明度滑块** | 0100 | 整层 `material.transparent = true; .opacity = v/100` | `layers[kind].opacity: 0..1` |
| **展开 / 折叠** | ▾ / ▸ | 展开后列出该层 mesh 节点(家具显示语义标签 + 缩略图) | UI 局部 state |
| **单节点 toggle** | 👁 | 仅隐藏某一个 mesh(家具层尤其常用:藏掉电视看墙) | `layers.furniture.hiddenItems: Set<itemId>` |
| **保存为视图** | ⊕ | 把当前 4 层可见性 + 相机状态打包成 "Named View",可分享/收藏 | 写入 `viewState`(见 §9 |
### 4.3 交互细节
| 触发 | 反馈 |
|------|------|
| 鼠标 hover 节点行 | 3D 场景中对应 mesh 加 `<Outline>` 高亮(淡黄色)+ 浮层显示尺寸/语义 |
| 点击节点行 | 相机飞到该 mesh + 在 3D 场景中长亮(橙色)+ 右侧弹出"材质/家具替换"面板(§5/§6) |
| 双击层标题 | 仅显示该层(其它 3 层临时隐藏),再双击恢复 |
| 拖拽不透明度滑块 | 实时(每帧)应用;松手时写入 store 触发自动保存 |
| 右键节点行 | 上下文菜单:复制 nodeId / 在新标签打开材质资产 / 报错(送 Sentry breadcrumb |
### 4.4 "图层组合"功能(Named Views
致敬 ArcGIS 的"图层组"概念,CrowdRoom 把"4 层可见性 + 相机 + 已应用的 overlay"打包成一个可分享、可命名的视图:
| 字段 | 例 |
|------|-----|
| `name` | "白模视角"、"只看家具"、"完成版" |
| `layer_toggles` | `{walls:true, floor:true, furniture:false, materials:true}` |
| `camera` | `{position:[2.4,1.6,3.0], look_at:[0,0.8,0], fov_deg:55}` |
| `overlay_id?` | 关联到某个 remix(可选) |
实现走 [`02_api_contract.md`](02_api_contract.md) E-10 `/functions/v1/view-state`token 在 URL 上:`/r/{room_id}?vs={token}`
> ✅ **契约 Web-Y4(原契约)**:解码 `vs=` 时**必须**对未知字段宽容(前向兼容),schema 升级时旧 token 不应失效。
>
> ✅ **契约 Web-Y4(本子任务新增声明)**viewState token 必须**确定性解码**gzip+base64url,无服务器随机种子),以便 `/api/og/r/[room_id]?vs=...` 的 headless Chromium 在 SSR 时**像素级复现**同一画面用于 OG 卡片;详见 §9。
### 4.5 LayerPanel React 组件骨架
```tsx
// components/layer-panel/LayerPanel.tsx
"use client";
import { useLayerStore } from "@/stores/layer-store";
import { Eye, EyeOff, Lock, Unlock, ChevronDown, ChevronRight } from "lucide-react";
import { Slider } from "@/components/ui/slider";
import type { LayerKind } from "@/types/manifest";
const LAYERS: LayerKind[] = ["walls", "floor", "furniture", "materials"];
export function LayerPanel() {
const layers = useLayerStore((s) => s.layers);
const toggle = useLayerStore((s) => s.toggleLayerVisible);
const lock = useLayerStore((s) => s.toggleLayerLocked);
const setOpacity = useLayerStore((s) => s.setLayerOpacity);
const expand = useLayerStore((s) => s.toggleExpanded);
return (
<aside className="w-80 border-l h-full overflow-y-auto bg-background">
<header className="p-3 flex items-center justify-between border-b">
<h2 className="text-sm font-semibold">Layers</h2>
<SaveViewButton />
</header>
<ul>
{LAYERS.map((kind) => {
const L = layers[kind];
const Expand = L.expanded ? ChevronDown : ChevronRight;
return (
<li key={kind} className="border-b">
<div className="flex items-center gap-2 p-2">
<button onClick={() => toggle(kind)} aria-label={`Toggle ${kind} visibility`}>
{L.visible ? <Eye size={16} /> : <EyeOff size={16} className="opacity-40" />}
</button>
<button onClick={() => lock(kind)} aria-label={`Lock ${kind}`}>
{L.locked ? <Lock size={16} /> : <Unlock size={16} className="opacity-40" />}
</button>
<Slider
className="flex-1"
value={[L.opacity * 100]}
onValueChange={([v]) => setOpacity(kind, v / 100)}
min={0} max={100} step={1}
aria-label={`${kind} opacity`}
/>
<button onClick={() => expand(kind)} aria-label={`Expand ${kind}`}>
<Expand size={16} />
</button>
</div>
{L.expanded && <LayerNodes kind={kind} nodes={L.nodes} />}
</li>
);
})}
</ul>
</aside>
);
}
```
`LayerNodes` 子组件渲染每个 mesh 的小行(含语义图标 + 缩略图 + 单节点 👁),代码同构。整个面板约 60 行 TSX 加上 `Slider` / `SaveViewButton` 共 ~120 行——shadcn/ui 已经把 A11y 做好,键盘 Tab/Space 即可操作所有按钮(呼应 §10)。
---
## 5. 材质替换 UX 🌟
> **本节直接回应用户原始需求"更换材质"。** 材质替换不改几何,是最轻量的 Remix 形态,必须做得"所见即所得"。
>
> ✅ **契约 Web-Y2**Remix 必须**浏览器内实时合成**(父 glb + 父 manifest + overlay),不请求服务端预合成。材质替换天然只改 `material.map`/`material.color`,完全可在 Web 端用 Three.js 一次性替换 PBR 槽位即可——这是 Web-Y2 落地的最佳证据。
### 5.1 进入材质替换的入口
| 入口 | 行为 |
|------|------|
| 在 3D 场景中点击墙/地/家具 mesh | 右侧抽屉切到 **"Material" tab**,展示该 mesh 当前 PBR 槽位(base_color / normal / roughness / metallic / AO |
| 在图层面板 Materials 层点击某个 `slot_id` | 同上 |
| 命令面板(`Cmd+K`)输入 "material" | 列出所有 slot,键盘选择 |
### 5.2 材质槽位面板(MaterialSlotPanel
```
┌─────────────────────────────────────┐
│ Material · floor_0 ▾ │
├─────────────────────────────────────┤
│ Current: │
│ [base_color preview] Oak Natural │
│ [normal preview] │
│ roughness ▓▓▓▓▓▓░░░░ 0.62 │
│ metallic ░░░░░░░░░░ 0.00 │
├─────────────────────────────────────┤
│ Replace with: │
│ [Wood] [Tile] [Fabric] [Metal] │
│ [Paint] [Wallpaper] [Favorites] │
│ ┌────┬────┬────┬────┐ │
│ │ 🪵 │ 🪵 │ 🪵 │ 🪵 │ ← 资产网格 │
│ └────┴────┴────┴────┘ │
│ [Load more] │
└─────────────────────────────────────┘
```
### 5.3 实时预览(不重载 .glb
替换流程**全部在浏览器内**完成,无网络往返(除资产纹理 GET):
```ts
// 伪代码:从 assets 表挑选新材质后
const newAsset = await fetchAsset(assetId); // GET /rest/v1/assets?id=eq.{id}
const tex = await ktx2Loader.loadAsync(newAsset.pbr.base_color_tex);
const targetMesh = scene.getObjectByName(slot.target_mesh_id) as Mesh;
const mat = targetMesh.material as MeshStandardMaterial;
mat.map = tex;
mat.roughness = newAsset.pbr.roughness;
mat.metallic = newAsset.pbr.metallic;
mat.needsUpdate = true;
// 写入 overlay 草稿(防抖 3 s 自动保存,见 §7)
overlayDraft.push({
op: "replace_material",
target_slot_id: slot.slot_id,
asset_id: assetId,
pbr_override: { ...newAsset.pbr },
});
```
### 5.4 资产库面板(AssetPickerMaterial
| 元素 | 设计 |
|------|------|
| 分类 Tab | 木材 / 瓷砖 / 布料 / 金属 / 油漆 / 壁纸 / 收藏夹(按 `assets.tags` 过滤;与 [`01_data_schema.md`](01_data_schema.md) §3.9 `assets.kind='material'` 对齐) |
| 网格视图 | 默认 4 列,每格 96×96,悬浮显示名称 + 来源 + 协议(CC0/CC-BY |
| 收藏夹 | 浏览器 `localStorage``favorite_asset_ids[]`;登录后写入 `users.favorites` 表(P2 加表) |
| 搜索 | 在分类内全文 `name + tags`;走 PostgREST `assets?or=(name.ilike.*q*,tags.cs.{q})` |
| 拖拽 | 支持把缩略图直接拖到 3D 场景中目标 mesh 上(HTML5 drag + raycaster 命中检测) |
| 协议筛选 | 顶部固定开关"仅显示 CC0"——MVP 默认开启,规避版权 |
### 5.5 保存为 Remixmaterial_overrides
材质替换写入 `remix_overlay.json``ops[]` 中([`02_api_contract.md`](02_api_contract.md) §4.2 已定义 `op: replace_material`):
```json
{
"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
}
}
```
**校验**Edge Function 在 `remix-publish` 时跑):
- `target_slot_id` 必须在父 manifest `materials.slots[]` 中存在且 `replaceable=true`
- `asset_id` 必须存在于 `assets` 表(`E-11` 失败时抛 `ASSET_NOT_FOUND`
- 失败 → 客户端回滚最近一次操作并 Toast 提示
### 5.6 单墙改色快捷操作(`set_wall_color`
不想拖整套贴图、只想试色时,提供"色环 picker"快捷入口:
- 点击墙 mesh → MaterialSlotPanel 顶部多一个 "Quick Color" 区
- 用 react-colorful 选色 → 直接 `material.color.set(hex)`
- 写入 overlay 的 `op: set_wall_color`[`02_api_contract.md`](02_api_contract.md) §4.3 已定义)
---
## 6. 家具替换 UX 🌟
> **本节直接回应用户原始需求"更换其他家具"。** 家具替换比材质替换复杂——要换几何 + 要对齐位置/朝向,对齐方案直接利用 [`01_data_schema.md`](01_data_schema.md) §5.2 中家具层强制保留的 `obb` 与 `anchor_point` 字段。
>
> ✅ **契约 Web-Y2 落地**:家具替换同样在浏览器内合成——隐藏父几何的 furniture 节点 + GLTFLoader 加载新 asset .glb,无需服务端介入。
### 6.1 进入家具替换的入口
| 入口 | 行为 |
|------|------|
| 在 3D 场景中点击家具 mesh | 右侧抽屉切到 **"Furniture" tab** + 自动按家具的 `semantic_class` 过滤资产库 |
| 在图层面板 Furniture 层点击某个 `item_id` | 同上 |
| 在资产库 `/assets?kind=furniture` 浏览时点 "Try in a room" | 进入 Remix 编辑器并默认锁定该 asset 为下一次点击的替换目标 |
### 6.2 替换面板(FurnitureSwapPanel
```
┌─────────────────────────────────────┐
│ Replace · bed_001 (semantic: bed) │
├─────────────────────────────────────┤
│ Original: │
│ OBB extent 2.00 × 0.60 × 1.50 │
│ anchor_point [-0.5, 0.0, -1.4] │
├─────────────────────────────────────┤
│ Suggested ("bed" assets): │
│ ┌────┬────┬────┬────┐ │
│ │ 🛏 │ 🛏 │ 🛏 │ 🛏 │ │
│ └────┴────┴────┴────┘ │
│ ☑ Snap to anchor (auto-align) │
├─────────────────────────────────────┤
│ Fine-tune (after select): │
│ X ▓░░░ +0.00 m │
│ Y ░░░░ +0.00 m │
│ Z ░░░░ +0.00 m │
│ Rot Y ⟳ 0° │
│ Scale ▓▓░░ 1.00x │
└─────────────────────────────────────┘
```
### 6.3 自动对齐(OBB-based
新家具的 anchor 与原家具 OBB 对齐遵循以下规则:
| 步骤 | 公式 / 行为 |
|------|------------|
| 1. 拉取新 asset 的 `anchor_point`(资产侧元数据,运营录入) | `assets.pbr.anchor_point` 或默认底面中心 `[0, -extent_y/2, 0]` |
| 2. 计算变换矩阵 `T` | `T = translate(original.anchor_point) · quat(original.obb.quat) · translate(-new_asset.anchor_point)` |
| 3. 应用到新 asset 的 Group | `assetGroup.matrix.copy(T); assetGroup.matrixAutoUpdate = false;` |
| 4. 若资产 OBB extent 与原 extent 比超过 1.5× | 给警告 toast "新家具明显大于原家具,可能溢出墙面" |
| 5. 隐藏原 furniture 节点 | `scene.getObjectByName(item.mesh_node_ids[0]).visible = false` |
> **关键设计动机回顾**[`01_data_schema.md`](01_data_schema.md) §5.2 让 `furnitureItem` 强制包含 `obb` 与 `anchor_point` 两个字段,正是为了让 Web 端能"零网络往返"实现自动对齐——这里是该设计的直接消费方。
### 6.4 手动微调
自动对齐之后,用户仍可在 X/Y/Z 平移 + Y 轴旋转 + 等比缩放四个自由度上微调(不开放任意 6DoF 自由度,避免家具"飘起来"或"贴墙穿模"):
| 自由度 | 范围 | UI |
|--------|------|------|
| 平移 X/Y/Z | ±0.5 m | 三个滑块 |
| 旋转 Y | 0360° | 圆形旋钮 |
| 缩放 | 0.7×–1.3× | 滑块;等比,禁止非等比避免视觉怪异 |
3D 场景中同时显示 Three.js `<TransformControls>`gizmo),与滑块双向绑定。
### 6.5 隐藏原家具 vs 删除原家具
> ✅ 关键决策:**永远是"隐藏 + 叠加新 asset",不删除原几何**。
理由:
- **Remix 可回退**:用户取消替换 → 让原 furniture 节点 `visible=true` 即可,无需重新下载 `canonical.glb`
- **存储零成本**overlay 只是 5 行 JSON,不复制几何
- **审计可追**:父房间作者能在自己的 dashboard 看到"我的房间被 N 个 remix 替换了 bed 这件家具"
- **不破坏 OBB 校验**:保留原节点意味着 manifest 始终自洽,未来若加"对比模式"(原版 vs Remix 并排)零改造
写入 overlay
```json
{
"op": "replace_furniture",
"target_item_id": "bed_001",
"asset_id": "a91c2...",
"asset_glb_uri": "assets/furniture/modern_bed_oak.glb",
"transform": {
"translate": [0.02, 0, -0.05],
"rotate_quat": [0.999, 0, 0.044, 0],
"scale": [1, 1, 1]
},
"snap_to_anchor": true
}
```
### 6.6 整体隐藏(`hide_layer`
如果用户想"看清房屋骨架",可在图层面板 furniture 层点 👁,写入 `op: hide_layer, layer_kind: furniture` 即可。这是"显示/隐藏"用户需求在批量场景下的快捷形态。
---
## 7. Remix 完整流程(Web-Y2 落地)
### 7.1 端到端序列图
> ✅ **契约 Web-Y2**:Remix 编辑器在浏览器内实时合成 = 父 .glb + 父 manifest + 本地 overlay 草稿,三者都不经过服务端预合成。
```mermaid
sequenceDiagram
autonumber
participant U as 用户
participant Web as Web Client
participant CDN as CDN
participant Edge as Edge Function
participant DB as Postgres
participant Shot as Headless Screenshot Worker
U->>Web: 在 /r/room_id 点 Remix
Web->>Edge: POST functions v1 remix-create parent_version_id title 空 overlay
Edge->>DB: insert remixes overlay 空 author_id auth.uid
Edge-->>Web: remix_id overlay_path
Web->>U: 跳转 /r/room_id/edit fork 1 携带 remix_id
Web->>CDN: GET canonical.glb 父版本
Web->>CDN: GET layer_manifest.json 父版本
CDN-->>Web: 两份文件 useGLTF 缓存命中即复用
loop 编辑会话
U->>Web: 切层 换材质 换家具 调相机
Web->>Web: 修改 overlayDraft Zustand
Web->>Web: 即时应用到 Three.js 场景
Note over Web: 防抖 3 秒
Web->>Edge: PATCH functions v1 remix-update overlay
Edge->>DB: update remixes overlay updated_at
Edge-->>Web: 200 ok last_saved
end
U->>Web: 点击 Publish
Web->>Edge: POST functions v1 remix-publish remix_id viewState
Edge->>DB: update remixes is_public true
Edge->>Shot: enqueue thumbnail job remix_id viewState
Shot->>CDN: GET canonical.glb 父
Shot->>Shot: headless Chromium 渲染 重放 viewState
Shot->>CDN: PUT thumbnail.webp
Edge-->>Web: published thumbnail_path
Web->>U: 跳转 /remix/remix_id 展示发布版
```
### 7.2 自动保存与冲突解决
| 场景 | 策略 |
|------|------|
| 单用户单设备编辑 | 防抖 3 s + 失焦时立即保存 + 关闭页签前 `beforeunload` 拦截 + 浏览器 `localStorage` 双重备份 |
| 同一用户多设备 | 后开的标签拿到更新的 `updated_at` 时,给"该 Remix 已在另一处被编辑"提示,让用户选"覆盖本地" / "丢弃本地" |
| 离线编辑 | overlay 草稿写 IndexedDB(用 `idb` 库),重新联网时尝试 PATCH;若返回 `REMIX_PARENT_DELETED` → 走下面的兜底 |
| 父房间被原作者硬删 | 抓 `REMIX_PARENT_DELETED`[`02_api_contract.md`](02_api_contract.md) §7.1 已定义)→ 弹窗:"父房间已被作者删除。你的修改可保存为独立副本(自动 fork 上一个已知 ready 的父快照)";提供"保存为独立副本"与"丢弃"两个按钮 |
| 父版本未 ready | 抓 `REMIX_PARENT_NOT_READY` → 跳回 `/r/{parent_room_id}` 并显示转码进度 |
| Overlay schema 升级 | overlay 顶部带 `schema_version`;旧客户端遇到新版字段时,对未知 `op` 跳过并 warning(前向兼容) |
### 7.3 操作历史与撤销
| 元素 | 设计 |
|------|------|
| 撤销栈 | Zustand `temporal` middleware;最多 50 步 |
| 快捷键 | `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z` 重做 |
| 历史面板 | 顶部"History"抽屉,列出 op 类型 + 时间戳;点任一行回到该状态 |
| 草稿版本 | 每次自动保存视为一个"快照";用户可在历史面板 fork 出某个早期快照为新 remix |
### 7.4 错误码与 UI 映射
| 业务码(来自 [`02_api_contract.md`](02_api_contract.md) §7) | 用户文案 | 行为 |
|---|---|---|
| `REMIX_PARENT_DELETED` | "原房间已被删除,是否保存为独立副本?" | 双按钮选择 |
| `REMIX_PARENT_NOT_READY` | "原房间正在处理中,请稍后再来 Remix" | 跳父详情显示进度 |
| `OVERLAY_INVALID` | "本次操作未通过校验:{detail}" | 回滚最后 1 op + 上报 Sentry |
| `ASSET_NOT_FOUND` | "该资产已下架,请选择其他材质/家具" | 资产卡片置灰 + 移出收藏 |
| `QUOTA_EXCEEDED` | "本月 Remix 配额已用完,下月 1 号重置" | 跳 `/me/quota` |
| `RATE_LIMITED` | "操作太快了,请稍后再试" | 30 s 倒计时 |
---
## 8. 浏览 / 搜索 / 发现 UX
### 8.1 首页瀑布流(`/`
致敬 Pinterest,但偏 3D 场景的"展柜感"
| 元素 | 设计 |
|------|------|
| 列数 | 桌面 4 列、平板 3 列、移动 2 列;用 CSS `column-count` + `break-inside: avoid` |
| 卡片宽高比 | 16:9(与缩略图 1280×720 对齐) |
| 卡片元素 | ① 缩略图(hover 时切到 `preview.mp4` 自动播放 5 s)② 标题 ③ 作者头像 + handle ④ ❤ 数 ⑤ 🔄 Remix 数 ⑥ 标签 chips(最多 3 个) |
| 列表分页 | 无限滚动 + `IntersectionObserver`;每页 20React Query infinite query |
| 排序切换 | 顶部 "Latest / Trending / Most Remixed / Following"Trending 走 `like_count / age^1.5` 衰减公式 |
| 筛选 | 顶部 Pill:户型 / 风格 / 城市 / 质量分(与 iOS 端 `quality_grade` 联动) |
| 空状态 | "还没有公开作品" + 引导上传按钮(仅登录用户可见) |
### 8.2 房间详情页(`/r/[room_id]`)布局
```
┌──────────────────────────────────────────────────────────┐
│ ← Home / Rooms / "我的客厅" ❤ 23 🔄 5 ⋯ │ ← 顶栏 + 操作
├──────────────────────────────────────────────────────────┤
│ │ Layers │
│ ┌──────────────────┐ │ 👁 Walls │
│ │ │ │ 👁 Floor │
│ │ 3D Canvas │ │ 👁 Furniture │
│ │ │ │ 🎨 Materials │
│ └──────────────────┘ │ │
│ │ [Save view] │
│ by @alice · 2 days ago · 阳台、北欧风 │ │
├──────────────────────────────────────────────────────────┤
│ Description ... │
│ Tags: 北欧 · 客厅 · 18m² │
├──────────────────────────────────────────────────────────┤
│ Comments (12) │
│ └─ @bob: 好看! │
│ └─ @carol: 那个沙发是哪里买的? │
└──────────────────────────────────────────────────────────┘
```
### 8.3 顶栏操作
| 按钮 | 行为 | 走的 API |
|------|------|--------|
| ❤ 点赞 | toggle + 数字本地 +1/-1 + 防抖 600 ms | `E-12 like-toggle`Web-Y3 |
| 🔄 Remix | 走 §7.1 序列图第 1 步 | `E-11 remix-create` |
| 📤 分享 | 弹分享面板(§8.4) | `E-10 view-state` |
| ⚠ 举报 | 举报弹层(reason 选项) | `E-14 report` |
| ⋯ 更多 | 嵌入代码 / 在新标签打开 / 复制 nodeId(开发者用) | 客户端 |
> ✅ **契约 Web-Y3**:点赞**必须**走 `/functions/v1/like-toggle`(幂等 + 防刷 5/s),禁止直接 INSERT/DELETE `likes` 表——见 [`02_api_contract.md`](02_api_contract.md) §8.2 Y3。本节顶栏 ❤ 按钮的实现严格走 E-12,且乐观更新 UI(先 +1,请求失败回滚)。
### 8.4 分享面板(SharePanel
| 元素 | 行为 |
|------|------|
| 复制链接 | `/r/{room_id}?vs={current_viewState_token}`,含当前图层组合 |
| 微博 / Twitter | 直链跳官方分享 endpoint,预填标题 + 链接 |
| 微信扫码 | 用 `qrcode` 库前端生成 PNG 二维码,提示"用微信扫一扫"(解决 iOS Safari → 微信深链难题) |
| 嵌入代码 | `<iframe src="https://crowdroom.app/embed/r/{room_id}?vs={token}" width="640" height="360" frameborder="0"></iframe>` 一键复制 |
| 下载缩略图 | 直接 GET `thumbnail@2x.webp`CDN 公开 URL |
### 8.5 评论区
| 元素 | 设计 |
|------|------|
| 列表 | 平铺时间序(最新在上),每条含头像/handle/正文/时间/回复按钮 |
| 回复 | 一级回复(`comments.reply_to`),不做多级嵌套(控制 UI 复杂度) |
| 提交 | 走 PostgREST `POST /rest/v1/comments`RLS 校验作者 = `auth.uid()`);乐观更新 |
| 长度限制 | 1000 字符上限,剩余字数实时提示;超出按钮置灰 |
| 删除 | 评论作者或房间 owner 可删(与 [`01_data_schema.md`](01_data_schema.md) §3.7 RLS 一致) |
> ✅ **契约 Web-Y6**[`02_api_contract.md`](02_api_contract.md) §8.2 已定义):评论提交后**乐观更新** UI,失败时回滚——`RLS_DENIED` 表示用户已被该房间作者拉黑,提示"暂无评论权限"。
### 8.6 资产库独立浏览页(`/assets`
为不想 Remix、只想"逛素材"的用户提供一个独立入口:
| 元素 | 设计 |
|------|------|
| 顶部 Tab | Furniture / Material |
| 侧栏筛选 | semantic_class(家具)/ tags(材质)/ licenseCC0 only 默认) |
| 卡片 | 缩略图 + 名称 + 协议 + 来源 + "Try in a room"(跳 Remix |
| 详情弹窗 | 3D 单品预览(`<model-viewer>` 即可,不上 R3F+ 元数据 |
---
## 9. SSR / SEO / OG 卡片(Web-Y4 落地)
> ✅ **契约 Web-Y4(强化版)**viewState `?vs=` token 必须**确定性解码**且**可被 SSR OG 截图服务复现**——即 `/api/og/r/[room_id]?vs={token}` 用 headless Chromium 渲染时,产出的 OG 图与用户当前浏览器画面像素级一致。这是 Web-Y4 在本子任务里的最终形态:前向兼容(原契约)+ SSR 可复现(本子任务新增)两件事一起绑死。
### 9.1 SSR meta 标签结构
每个 `/r/[room_id]` SSR 时输出:
```html
<meta property="og:title" content="我的客厅 by @alice · CrowdRoom" />
<meta property="og:description" content="北欧风 · 18.4 m² · 6 件家具 · 23 ❤" />
<meta property="og:image" content="https://crowdroom.app/api/og/r/{room_id}?vs={token}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://crowdroom.app/r/{room_id}?vs={token}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://crowdroom.app/api/og/r/{room_id}?vs={token}" />
<link rel="canonical" href="https://crowdroom.app/r/{room_id}" />
```
### 9.2 OG 卡片动态生成 `/api/og/r/[room_id]`
| 阶段 | 行为 |
|------|------|
| 解析 query | 解码 `?vs={token}`gzip+base64url)得到 `{layer_toggles, camera, overlay_id?}` |
| 拉取数据 | CDN GET 父 `canonical.glb` + 父 `layer_manifest.json` + 可选 overlay |
| 渲染 | Vercel Edge Function 内 headless Chromium`@vercel/og` + `three.js` worker)以 1200×630 渲染一帧 |
| 缓存 | `Cache-Control: public, s-maxage=86400, stale-while-revalidate=604800`;缓存键 = `room_id + version_no + viewState_hash` |
| 失败兜底 | 渲染超时 → 返回 `thumbnail@2x.webp` 静态缩略图(仍是合格 OG 图) |
> **为什么把 OG 生成放在 Edge 而不是转码 Worker**:转码 Worker 一次只跑一份 `canonical thumbnail`,无法为每个 viewState 变体单独生成;Edge 按需 + 长缓存最划算。MVP 可暂时只生成"默认视图"的 OG 图(即忽略 `?vs=` 时直接返回 `thumbnail@2x.webp`),把动态 OG 列入 P1。
### 9.3 viewState token 编码契约(确定性)
| 字段 | 序列化规则 |
|------|--------|
| 排列顺序 | 固定 alphabetical`camera` < `layer_toggles` < `overlay_id`),保证哈希稳定 |
| 浮点精度 | 相机位置/look_at 保留 3 位小数;fov 保留 1 位 |
| 编码 | `gzip``base64url`(无 padding |
| 版本 | 头部 1 字节 magic `0x01` 标识 schema_version |
| 解码宽容 | 未知字段忽略 + warningWeb-Y4 原契约) |
> 该编码契约同步给 `view-state` Edge Function[`02_api_contract.md`](02_api_contract.md) E-10)——服务端与客户端使用**同一份** TypeScript 库(`packages/viewstate-codec`Turborepo 共享包),保证编码一致。
### 9.4 robots.txt 与 sitemap
| 资源 | 规则 |
|------|------|
| `robots.txt` | 允许爬 `/``/r/*``/u/*``/assets``/about`;禁爬 `/r/*/edit``/me/*``/notifications` |
| `sitemap.xml` | 每天定时(pg_cron)生成;含所有 `visibility='public'` 的 rooms 与 remixes |
| Hreflang | `<link rel="alternate" hreflang="zh-CN"...>``hreflang="en-US"` 配对 |
| 结构化数据 | 房间详情页输出 schema.org `3DModel` JSON-LD(提升 Google rich result |
### 9.5 CDN 与 JWT 严格分离
> ✅ **契约 Web-Y5**:缩略图与 `.glb`/`.json` 一律走 CDN 公开 URL`/storage/v1/object/public/...`),**不要**发起带 JWT 的请求——见 [`02_api_contract.md`](02_api_contract.md) §8.2 Y5。
实现要点:
- Supabase JS Client 拉公开资源时使用 `getPublicUrl(path)`**禁止**走 `download(path)`(后者会带 JWT 命中私有 bucket 路径)
- 公开 CDN URL 写入 manifest 的 `glb_uri` 时已是相对路径,前端拼前缀 `process.env.NEXT_PUBLIC_CDN_BASE` 即可
- Sentry breadcrumb 监控所有对 `/storage/v1/` 的请求,若 header 带 `Authorization` 直接抛 dev 警告
---
## 10. 性能与可访问性
### 10.1 性能优化
| 项 | 措施 |
|----|------|
| Code splitting | Three.js + R3F 在 `/r/*` 路由动态 `import('@react-three/fiber')`;首页 bundle ≤ 180 KB gzip |
| 图片 | Next/Image + AVIF/WebP 自动协商;缩略图主图 priority + lazy 同屏 |
| 字体 | next/font 本地内联,subset 仅含中英常用字符 |
| 关键资源 preload | `<link rel="preload" as="fetch" href="...layer_manifest.json">`(详情页 SSR 时输出) |
| 路由预取 | `<Link prefetch>` 默认开(卡片 hover 预拉 manifest |
| Service Worker | MVP 不上 PWAP1 可加(offline 缓存 manifest + 资产) |
| Web Vitals 目标 | 首页 LCP < 2.5 s / 详情页 LCP < 3 s(不含 3D 渲染)/ INP < 200 ms |
| 监控 | Vercel Analytics + PostHog 自定义事件 `3d_first_frame_ms` |
### 10.2 可访问性(A11y
| 项 | 措施 |
|----|------|
| 键盘导航 | 所有按钮 / 滑块 / 列表 Tab 可达;Esc 取消当前选中 mesh / 关闭面板 |
| 3D Canvas 不可达 fallback | 提供"图层 + 房间元数据"的纯 HTML 视图(`<details>` 列出 manifest 内容),ARIA `role="img" aria-label="3D 房间预览"` |
| 颜色对比 | WCAG AA 级;图层面板的"已隐藏"状态用 0.4 opacity + 图标双重提示,不只靠颜色 |
| 屏幕阅读 | LayerPanel 每行有 `aria-label="Walls layer, visible, opacity 80%"` |
| 动效 | 尊重 `prefers-reduced-motion`,关闭相机飞行缓动 |
| 国际化 | `zh-CN` / `en-US` 双语,路由前缀 `/zh` / `/en`(默认 `/zh` |
### 10.3 浏览器兼容矩阵
| 浏览器 | 最低版本 | 3D 体验 | 备注 |
|--------|---------|---------|------|
| Chrome / Edge | 110+ | 全功能 | 主目标 |
| Firefox | 110+ | 全功能 | KTX2 需要启用 WebGL2(默认开) |
| Safari (macOS) | 16+ | 全功能 | 关掉阴影避免卡顿 |
| Safari (iOS) | 16+ | 30fps 限速 | 同 §3.3 移动端预算 |
| 微信内嵌 X5 | 部分 | iOS 走 WKWebView 同 SafariAndroid X5 fallback 到 `<model-viewer>` | 详情页 banner 提示"用浏览器打开体验更佳" |
---
## 11. MVP 范围与不做项
### 11.1 MVP8 周内交付,与 [`00_overview.md`](00_overview.md) §7.1 对齐)
| 模块 | 交付物 |
|------|--------|
| 路由骨架 | R-01 / R-02 / R-03 / R-04 / R-06 / R-07 / R-09 / R-10 / R-13 / R-15 共 10 条 |
| 3D 渲染 | manifest 4 层 group + OrbitControls + fit-to-bbox + Outline 高亮 |
| 图层面板 | §4 全部交付(4 层 + 显示/隐藏 + 锁定 + 不透明度 + 单节点 toggle + Save View |
| 材质替换 | §5 全部(资产库筛选 + 实时预览 + 写入 overlay + 单墙改色) |
| 家具替换 | §6 全部(同语义筛选 + OBB 自动对齐 + 手动微调 + 隐藏原节点) |
| Remix 编辑 | §7.1 全流程 + §7.2 单设备自动保存 + §7.4 6 条错误码兜底 |
| 浏览 / 搜索 | §8.1 / §8.2 / §8.3 / §8.5(评论、点赞)+ §8.4 复制链接与嵌入代码 |
| 登录 | Supabase Auth:邮箱 + Sign in with Apple + Sign in with Google |
| 个人主页 | `/u/[handle]` + `/me`(含配额展示 §E-15 |
| SEO 基本盘 | SSR + 静态 OG(用 `thumbnail@2x.webp`+ sitemap + robots |
| i18n | 中文/英文双语切换 |
| 监控 | Sentry + PostHog event funnel |
### 11.2 明确不做(MVP 外)
-**实时多人协同编辑**Remix 走 fork,与 iOS Flow B 决策一致)
-**VR / AR 模式**drei `@react-three/xr` 留口子,P2 再上)
-**AI 自动配色 / 风格推荐**(成本与争议都大)
-**电商导流 / 家具购买链接**(合规审查负担,P2 才考虑)
-**付费贴图市场 / 创作者分成**(社区先长内容再谈商业化)
-**动态 OG 卡片**(按 viewState 像素级复现的 OG 列入 P1,MVP 用静态缩略图)
-**PWA / 离线模式**
-**3D 编辑器加新几何**(如新增装饰墙、新增门窗)——MVP 仅允许"换"与"隐藏",不允许"加几何"
-**多人评论实时推送**(评论提交后用 React Query 失效缓存重拉,不接 Realtime channel
-**历史快照 fork**(§7.3 提到的"从某早期快照 fork 新 remix"列入 P1
---
## 12. 风险与开放问题
| # | 风险 / 开放问题 | 当前判断 | 待后续讨论 |
|---|---------------|---------|-----------|
| **R-Web-1** | **移动端 Safari 上 Three.js 性能下限**iPhone 13 Mini / SE 等设备在大场景(>3 MB)可能跌到 20 fps | §3.3 已定移动预算 ≤ 2 MB;若 95 分位仍掉帧,则 Tier 1 自动降到 `<model-viewer>` 静态预览 | 是否需要后端转码 Worker 额外产 `canonical_mobile.glb`(更激进压缩)? |
| **R-Web-2** | **大场景首屏 TTI**>20 MB `.glb`(实际有用户扫整套别墅)会让 LCP > 6 s,OG 截图 Edge 函数也会超时 | 上传配额已限 single .glb ≤ 15 MB[`02_api_contract.md`](02_api_contract.md) §6 creator 档),但 100 MB 房间已在用户调研中出现 | 是否要在转码阶段强制拒收超 50 MB 的 .glb?或者 P1 引入 LOD |
| **R-Web-3** | **自动对齐 OBB 朝向不一致**RoomPlan 导出的 `obb.quat` 偶尔与新 asset 的 `anchor_point` 朝向相差 90°(如沙发朝向墙的方向) | §6.3 给出"超 1.5×"的告警,但**朝向**没有自动校正 | 是否在资产侧元数据中加 `facing_direction`front/back/left/right),用启发式对齐? |
| **R-Web-4** | **离线 Remix 草稿与父版本删除的冲突**:用户离线 1 周编辑,期间父房间被作者硬删 | §7.2 抓 `REMIX_PARENT_DELETED` → 提示保存独立副本;但"独立副本"是否承诺保留父几何快照?涉及版权与法务 | 子任务 5 隐私治理章节需明确:父房间被作者删除后,其几何快照能否被未发表的 remix 继承 |
| **R-Web-5** | **公共资产库的版权审查负担**:MVP 只用 CC0,但用户社区可能要求引入 CC-BY 甚至付费素材 | 暂只接 CC0;前端硬过滤所有非 CC0 的 asset | 何时上"创作者上传素材"通道?需要审核流水,子任务 5 共同规划 |
| **R-Web-6** | **Vercel Edge Function 不支持 WebGL**headless Chromium 在 Edge 上的 GPU 不可用) | §9.2 的"动态 OG"实际需放在容器(Fly.io)而不是 Vercel Edge | 是否引入专门的"screenshot worker"容器?还是 MVP 完全跳过动态 OG? |
---
## 13. 给子任务 5(隐私治理)的契约要点
Web 端在分享面板(§8.4)、OG 卡片(§9.2)、Remix 失败兜底(§7.2 父被删后的独立副本)、资产库版权(R-Web-5)处与隐私治理章节有强耦合。子任务 5 撰写隐私治理总章时**必须保证**以下要点:
| # | 治理章节必须保证 | 与本章对应 |
|---|------------------|-----------|
| **P-W-1** | 明确"viewState 分享链接"能否泄露隐藏图层的内容——即第三方拿到 `?vs=...` 后能否反向取消隐藏看到原始几何 | §4.4 + §9.3viewState 只记录"可见性"开关,**不持有几何**,因此天然不泄露隐藏几何;治理章应文字明确这一点 |
| **P-W-2** | 明确 OG 卡片中"作者头像 + 房间标题"是否对外公开——`unlisted` 房间是否允许 OG 图被搜索引擎抓取 | §9.1 + §9.4:`unlisted` 房间应在 robots.txt 阻止 OG endpoint,仅持链访问 |
| **P-W-3** | 明确 Remix 父房间被作者硬删后,已发表的 remix 是否可继续承载父几何快照(涉及"作者撤回权 vs Remixer 既得权"冲突) | §7.2 + R-Web-4 |
| **P-W-4** | 明确公共资产库的协议白名单——是否在 P1 开放 CC-BY?是否要求二次创作 attribution | §5.4 + R-Web-5 |
| **P-W-5** | 明确举报通道(E-14)在 Web 端的可达性——是否要求所有公开页面都有"举报"按钮 | §8.3 ⚠ 举报按钮 |
| **P-W-6** | 明确"嵌入代码"iframe)的频次/速率限制——iframe 嵌入到广告联盟时是否计入原房间作者的流量计费 | §8.4 嵌入代码 |
| **P-W-7** | 明确视图分享 `?vs=` 是否可被 Realtime 服务用作"用户偏好"画像(合规风险) | §9.3 viewState 编码契约 |
---
## 14. 本章小结
| 关键产出 | 一句话 |
|----------|--------|
| **15 条路由 / 10 项技术栈** | Next.js 14 App Router + R3F + Zustand + Supabase SSR + Tailwind/shadcn,部署 Vercel |
| **3D 渲染契约** | manifest → 4 层 group → Zustand 双向绑定;桌面 60 fps / 移动 30 fpsMVP 不做 LOD |
| **类 ArcGIS 图层面板** | 4 层固定 ID + 👁/🔒/不透明度/单节点 toggle + Named Views 分享;直接回应用户原始需求"显示/隐藏" |
| **材质替换 UX** | 浏览器内 `material.map` 替换 + 资产库 CC0 默认 + overlay 5 行 JSON;直接回应"换材质" |
| **家具替换 UX** | OBB 自动对齐 + 4 自由度微调 + 隐藏原节点不删除;直接回应"换其他家具" |
| **Remix 浏览器实时合成** | 父 .glb + 父 manifest + 本地 overlay 三件套,零服务端预合成;防抖 3 s 自动保存 + 6 条错误码兜底 |
| **OG 卡片可复现 Web-Y4** | viewState gzip+base64url 确定性编码;Edge 截图按需缓存 24 h |
| **6 条契约落地** | Web-Y1 §3 / Web-Y2 §5 §6 §7 / Web-Y3 §8.3 / Web-Y4 §4.4 §9 / Web-Y5 §9.5 / Web-Y6 §8.5 |
| **7 条治理移交** | viewState 隐私 / unlisted OG / Remix 父删除 / 资产协议白名单 / 举报可达 / 嵌入流量计费 / vs token 画像 |
读完本章你应能:
- ✅ 给 Web 工程师一份 8 周内可交付的功能清单与路由蓝图
- ✅ 评审"换材质 / 换家具 / 图层切换"三大核心交互的端到端可行性
- ✅ 知道 Web-Y1~Y6 每条契约具体落在哪一节
- ✅ 接手子任务 5 时知道隐私治理章节要回答 Web 侧的 7 个问题
---
**章节版本**v0.1 · 草案
**关键收获**Web 端是 CrowdRoom 直接面向"灵感党 + Remixer"的体验门面——用 Next.js + R3F 把 [`01_data_schema.md`](01_data_schema.md) 的 `layer_manifest.json` 和 [`02_api_contract.md`](02_api_contract.md) §4 的 `remix_overlay.json` 翻译成"类 ArcGIS 分层 + 拖拽换材质换家具"的消费级体验;所有 Remix 合成都发生在浏览器,服务端只承担鉴权、存储、防刷与可选 OG 截图。
File diff suppressed because it is too large Load Diff
+364
View File
@@ -0,0 +1,364 @@
# CrowdRoom · 隐私设计(v0.1
> 本章是 CrowdRoom 的「**合规底座**」,承接 [`00_overview.md`](00_overview.md) §8 风险 RK-3(UGC 审核)、RK-4(隐私脱敏),并对 [`03_ios_app_plan.md`](03_ios_app_plan.md) §11 移交的 **P-1 ~ P-6** 与 [`04_web_app_plan.md`](04_web_app_plan.md) §13 移交的 **P-W-1 / P-W-2 / P-W-7** 三条隐私强相关契约逐一拍板。治理与社区规则(含 P-W-3 ~ P-W-6)见 [`10_governance.md`](10_governance.md)。
>
> 本章不重复 [`01_data_schema.md`](01_data_schema.md) §3.5 `redactions` 表 DDL 与 §3.5 RLS 策略——所有「实现位置」均回指既有章节。
>
> **本章核心论断**CrowdRoom 用「**端侧脱敏 + 公共数据 CDN 全裸 / 私有数据 RLS 全锁**」两段式架构把隐私风险压缩到「**用户主动框选发布**」这一个决策点上;服务端永不二次检测人脸、永不持有 GPS 精确坐标、永不把 `redactions` 区域明文回流到第三方。
---
## 1. 隐私设计五原则
> 排列顺序即决策优先级。当任意两条原则冲突时,**编号小的优先**。
| # | 原则 | 一句话定义 | 落地证据 |
|---|------|-----------|---------|
| **PR-1** | **端侧优先脱敏** | 任何可能含人脸/人体的栅格数据,必须在 iPhone 离开 App 进程前完成 `CIGaussianBlur` 不可逆改写;服务端**永不**对原始贴图做二次人脸检测。 | [`03_ios_app_plan.md`](03_ios_app_plan.md) §4 端侧脱敏管线 + iOS-X1 契约 |
| **PR-2** | **最小数据采集** | 不申请非必要权限:不录音、不读相册、不收 IDFA、不收精确 GPS。能用 5 km 城市标签解决的就不上经纬度,能用客户端聚合的就不收原始事件。 | §3.3 位置生命周期 + §9 第三方 SDK 清单 |
| **PR-3** | **用户可控** | 所有「涉及隐私的开关」默认朝隐私最严方向(私有可见性、不打位置标签、不保留备份),用户可在「我的 → 隐私」一处看完全部并即时切换。 | §7 默认值清单 |
| **PR-4** | **默认私有,发布显式** | 房间记录在数据库里初始 `visibility='private'`(与 [`01_data_schema.md`](01_data_schema.md) §3.2 `rooms.visibility` 默认值绑定),用户必须在上传表单**主动**勾选「公开」才会进入发现流——零误公开。 | [`03_ios_app_plan.md`](03_ios_app_plan.md) §1.2 UploadForm 页 |
| **PR-5** | **可被遗忘** | 用户「注销账号并删除全部数据」是一个**承诺 30 天内完成**的端到端流程,不存在「冷备份永久保留」的暗逻辑;公开 Remix 的几何快照按 §8 保留期表自动迁移到 tombstone 后清理。 | §3.1 P-4 + §8 删除时间表 |
> 若未来运营方提出「为了广告变现请放开 IDFA」,本五条原则即「**先改原则再改代码**」的硬门槛——任何放开必须更新本章并在 [`00_overview.md`](00_overview.md) 顶部 changelog 留痕。
---
## 2. 数据流隐私视图
> 红/黄/绿 = 该节点持有数据的隐私敏感度(红 = 含可识别人脸/位置,黄 = 含间接可识别信息,绿 = 已脱敏或仅元数据)。第三方可访问性以「✓ 外部可见」与「✗ 仅内部」标注。
```mermaid
graph LR
subgraph 设备_iPhone
A1[RoomPlan 原始 RGB Depth · 红 · ✗ 仅内部]
A2[Vision 人脸 bbox · 红 · ✗ 仅内部]
A3[CIGaussianBlur 改写贴图 · 绿 · ✗ 仅内部]
A4[redactions list 数组 · 黄 · ✗ 仅内部]
end
subgraph Supabase_Storage
B1[private rooms id source usdz · 绿 已脱敏 · ✗ JWT 锁]
B2[private rooms id source roomplan json · 黄 含尺寸 · ✗ JWT 锁]
B3[public rooms id canonical glb · 绿 · ✓ CDN]
B4[public rooms id thumbnail webp · 绿 · ✓ CDN]
end
subgraph Supabase_Postgres
C1[rooms 表 location_label 城市级 · 黄 · ✓ RLS public]
C2[redactions 表 region 坐标 · 红 · ✗ RLS owner only]
C3[users 表 handle email · 黄 · ✓ handle 公开 email RLS]
end
subgraph 转码_Worker
D1[usdz to glb 流水 · 绿 仅几何 · ✗ service role]
D2[Worker 日志 含失败堆栈 · 黄 · ✗ Sentry 关联]
end
subgraph Web_浏览端
E1[R3F 渲染 glb manifest · 绿 · ✓ 公开]
E2[viewState 分享 token · 绿 仅开关 · ✓ URL 上]
E3[OG 图缓存 1200x630 · 绿 · ✓ CDN]
end
subgraph 第三方_SDK
F1[Sentry Issue 含堆栈 · 黄 · ✗ 内部账号]
F2[PostHog 事件 已脱敏 · 黄 · ✗ 内部账号]
F3[CloudFlare Vercel 边缘缓存 · 绿 仅公开资产 · ✓ 全球节点]
end
A1 --> A2 --> A3
A3 --> B1
A2 --> A4 --> C2
A1 -.丢弃.-> X[本地不留底]
B1 --> D1 --> B3 --> E1
D1 --> B4 --> E3
C1 --> E1
E1 --> E2
D2 -.错误才上报.-> F1
E1 -.事件聚合.-> F2
B3 --> F3
```
**读图要点**
- 红色节点(A1/A2/C2**永不**离开「设备」或「Supabase 内网 + service_role」边界
- 黄色节点(A4/B2/C1/C3/D2/F1/F2)受 RLS 或 Sentry 项目权限保护,不直接对公网开放
- 绿色节点(A3/B3/B4/D1/E1/E2/E3/F3)走公开 CDN,但在到达此节点前已经过端侧脱敏 + 转码 Worker 几何重打包,**不含任何原始 RGB**
- 唯一一处「原始数据 → 公开」的转换发生在 **端侧脱敏管线**A3 出口)——这条转换链由 [`03_ios_app_plan.md`](03_ios_app_plan.md) §4 强约束
---
## 3. 三类敏感数据全生命周期
### 3.1 人脸 / 人体
| 阶段 | 行为 | 是否离设备 |
|------|------|-----------|
| **采集** | RoomPlan 在 ARKit 帧上累积 RGB 贴图 | 否 |
| **检测** | `VNDetectFaceRectanglesRequest` Revision 3 跑在 Neural Engine,输出归一化 bbox | 否 |
| **脱敏** | `CIGaussianBlur radius=18` 整图模糊 + 蒙版合成回原图,bbox 区域**不可逆**改写到磁盘 | 否 |
| **审计写入** | bbox 累积为 `redactions[]` 数组(`kind='face', space='texture', method='gaussian_blur_r18'`),仅作合规审计 | 否 |
| **上传** | `redactions[]``upload-complete` POST 到 Supabase,写入 `redactions` 表 | 是(坐标) |
| **服务端** | **不二次检测人脸**(成本与合规权衡:再次解码 RGB 反而提高数据持有等级);Worker 只读已脱敏 .usdz | — |
| **用户可见** | 「我的房间 → 该房间 → 隐私详情」展示「本次扫描共检测到 N 张人脸,全部已模糊」;点开可看缩略图列表(每张含人脸 bbox 轮廓);提供「申请重做脱敏」按钮(走人工流) | 否(owner-only |
| **保留** | 与 `room_versions` 同生命周期(见 §8 |
| **删除** | `rooms` 硬删 → `room_versions` cascade → `redactions` cascade | — |
> 关键决策:服务端**不复检**而是**信任端侧**。理由:① 复检意味着服务端必须解码原始 RGB,反而把数据持有等级从「绿」升回「红」;② Vision Revision 3 在 1024² 贴图上召回 ≥ 95%Apple 官方 benchmark),漏检的极端 case 走 §4 P-2 的用户复议路径解决。
### 3.2 可识别地标 / 文档 / 屏幕内容
| 阶段 | 行为 |
|------|------|
| **MVP 不做自动检测** | OCR + 地标识别会把数据流从「人脸」一类扩展到「文字 + 品牌 logo + 证件号」,模型大小与误报代价远超 MVP 8 周窗口承受 |
| **替代方案:手动框选** | iOS App 在「扫描完成预览页」([`03_ios_app_plan.md`](03_ios_app_plan.md) §1.2 ScanReview)提供 `UIScrollView` 缩略图墙,用户长按贴图后框选矩形;Web Remix 编辑器([`04_web_app_plan.md`](04_web_app_plan.md) §7)也提供同款工具 |
| **数据落库** | 框选区域写入 `redactions[]` with `kind='manual', method='gaussian_blur_r18'`;Worker 在转码时拿到该数组,对应 mesh 贴图做**二次模糊**(这是 §3.1 之外服务端唯一对栅格做的隐私操作,且仅对用户主动标注的区域) |
| **用户提示** | 上传表单底部固定文案:「请确保扫描中没有信用卡 / 身份证 / 屏幕显示的私人信息 / 他人住址快递单。如有,请使用上一页『手动模糊』工具框选。」 |
| **复议** | 房间发布后,owner 在详情页底部「隐私审查」入口可追加框选 → 触发 [`02_api_contract.md`](02_api_contract.md) §3.2 的 `transcode-retry` 重跑 |
### 3.3 地理位置
| 阶段 | 行为 |
|------|------|
| **权限文案** | `NSLocationWhenInUseUsageDescription`「CrowdRoom 可选使用你的位置,仅为给你扫描的房间打上『城市』标签」([`03_ios_app_plan.md`](03_ios_app_plan.md) §8.1 |
| **精度截断** | iOS App 拿到 `CLLocation` 后,**立即**用 reverse-geocoding 得到城市名(`CLPlacemark.locality`),原始经纬度**不进入** App 持久层、不进入 SwiftData、不进入网络请求 body |
| **服务端存储** | `rooms.location_label TEXT`(如 `'上海'``'San Francisco'`),**没有** `lat/lng` 列。即便日后想做「地图视图」也必须用城市质心而非用户原始坐标 |
| **公开可见性** | 城市标签默认随房间 `visibility` 走(public 房间公开城市;private/unlisted 房间该字段对外不可见,由 RLS 保证) |
| **关闭** | 用户在「我的 → 设置 → 隐私 → 位置打标」一键关闭后,所有未来上传的 `location_label = NULL`;已上传房间提供「移除位置标签」按钮(直接 UPDATE NULL |
| **不可恢复** | 关闭后无法找回原始 GPS——因为根本没存过 |
> 当 Apple 推出更高精度的 `CLLocationAccuracyReduced`(IPv6 地区已默认)时本项收益更明显:CrowdRoom 从一开始就比 iOS 系统更严格。
---
## 4. iOS 移交契约(P-1 ~ P-6)逐条决策
### P-1 — 端侧脱敏失败 3 次的降级策略
> **决策**:✅ **不降级到服务端二次脱敏;3 次失败后必须用户介入**(手动框选 或 重新扫描),坚决不破例 iOS-X1。
>
> **理由**:① 服务端二次脱敏需要解码原始 .usdz 贴图,本质是把数据持有等级从「已脱敏 绿」升回「未脱敏 红」,违反 PR-1;② 端侧失败 3 次的根本原因多为 `ModelIO` 解贴图异常或贴图格式罕见(HDR / 浮点 PNG),这些场景手动框选反而比模型更准;③ 8 周 MVP 期内服务端没有 GPU 推理预算(成本 + 隐私双不划算)。iPhone 12 Pro 上 16 s 的 R-iOS-2 风险用「文案明确告知预计 15 秒」的 UX 兜底,不动 X1。
>
> **实现位置**[`03_ios_app_plan.md`](03_ios_app_plan.md) §4.4 失败兜底 + §10 R-iOS-2 + iOS-X1 契约文字保留原样
### P-2 — `redactions[]` 保留期与用户可见性
> **决策**:✅ **`redactions[]``room_versions` 同生命周期**(版本删则 cascade 删除);**仅 owner 可见**[`01_data_schema.md`](01_data_schema.md) §3.5 `redactions_owner_only` policy 已落地);owner 可在「我的房间 → 隐私详情」页看到每个 bbox 的缩略图,并对漏检/误检发起「申请重做」工单。
>
> **理由**:① 同生命周期保证「删房 = 彻底删审计」,避免「我删了房,但脱敏记录还在数据库里」的尴尬;② owner-only 是为了防止攻击者通过 bbox 坐标反推真实人脸位置(即便贴图已模糊,反推「模糊区域曾经是某熟人面孔」仍构成隐私泄露);③ 用户可见性是 PR-3 的硬要求——用户必须能验证「我相信的脱敏真的发生了」。
>
> **实现位置**[`01_data_schema.md`](01_data_schema.md) §3.5 `redactions` 表 + RLS `redactions_owner_only` + [`03_ios_app_plan.md`](03_ios_app_plan.md) §1.2 PrivacyPage(在「我的房间」详情下新增「隐私详情」子页)
### P-3 — 位置标签是否公开展示
> **决策**:✅ **城市级(5 km 精度)随房间可见性公开展示;精确 GPS 永不上传、永不存储**`NSLocationWhenInUseUsageDescription` 文案补充一句「该城市标签会显示在你的公开房间页」。
>
> **理由**:① 城市标签是发现流的强信号(「上海北欧风客厅」远比「北欧风客厅」更有用),完全屏蔽损失体验;② 5 km 精度无法定位到楼栋,符合 GDPR Article 4(1) 中「无法识别自然人」的去标识化阈值;③ 文案补强是 PR-2 + PR-3 的衍生——用户必须在授权时就知道这会公开,而不是事后惊讶。
>
> **实现位置**[`03_ios_app_plan.md`](03_ios_app_plan.md) §8.1 权限文案(需新增「会显示在公开房间页」句子)+ [`01_data_schema.md`](01_data_schema.md) §3.2 `rooms.location_label` 字段 + [`04_web_app_plan.md`](04_web_app_plan.md) §8.2 详情页元信息行
### P-4 — 用户删除全部数据的端到端流程
> **决策**:✅ **「注销账号并删除全部数据」一键流程,承诺 30 天内完成端到端清理**,分四阶段:
>
> | 阶段 | 触发 | 数据状态 | 用户可挽回 |
> |------|------|---------|-----------|
> | T+0 (即时) | 用户点击「确认注销」+ 二次密码确认 | `users.deleted_at = now()``rooms.visibility = 'private'`(立即从 Feed 与搜索消失);所有 Remix 走 §10 治理 P-W-3 的快照转移流;账号登录被阻断 | ✅ 7 天内联系 DPO 邮箱可撤销 |
> | T+7 (软删完成) | pg_cron 每日 02:00 扫表 | 数据库行打 `deleted_at`Storage 公开 bucket 中的 `canonical.glb / thumbnail` 仍存(继续承载已发表 Remix) | ❌ |
> | T+30 (硬删完成) | pg_cron 第 30 天扫表 | 删除 `rooms`/`room_versions`/`redactions`/`comments`/`likes`/`users` 行;Storage 私有 bucket`source.usdz / source.roomplan.json`)整目录删除;Sentry 与 PostHog 通过 `user_id` 关联事件按 SDK API 调用删除 | ❌ |
> | 持续 | 公共 Remix 的几何快照(§10 P-W-3 决策)保留为「孤儿作品」,作者署名替换为「Former CrowdRoom user」 | — | — |
>
> **理由**:① 30 天硬删窗口对齐 GDPR Article 17 的「一个月内响应」+ 给运营充分时间处理异议;② 7 天软删让用户冷静期,避免冲动注销后悔(实测电商类产品冲动注销撤销率 8–15%);③ Remix 几何快照保留是 P-W-3 「Remixer 既得权」的衍生(详见 [`10_governance.md`](10_governance.md) §4 P-W-3)。
>
> **实现位置**[`03_ios_app_plan.md`](03_ios_app_plan.md) §1.2 SettingsPage 新增「注销账号」子页 + [`04_web_app_plan.md`](04_web_app_plan.md) R-10 `/me` 新增同款入口 + 新增 Edge Function `account-delete`(待 [`02_api_contract.md`](02_api_contract.md) v0.2 补 E-16
### P-5 — ATT 启用触发条件
> **决策**:✅ **MVP 不申请 ATT;当且仅当以下任一条件首次满足,才在下一个 minor 版本灰度推 ATT 弹窗**
>
> 1. 接入 Apple Search Ads 归因(需读取 `attributionToken`
> 2. 接入任何把 IDFA 出域的第三方 SDK(如 AppsFlyer、Adjust、字节穿山甲)
> 3. 与广告联盟/数据合作方做 user-level 数据交换
>
> Sentry、MetricKit、PostHog(已配置为「不收 IDFA、不开 Session Replay」)均不触发以上任一条件,因此 MVP 上线时 `NSUserTrackingUsageDescription` 字段**不在** Info.plist 中。
>
> **理由**:① 不申请 = 不需要审核 ATT 文案,免去 App Store 拒审风险;② 一旦满足触发条件再补,发版周期约 1–2 周,对业务节奏影响极小;③ 与 PR-2 「最小数据采集」严格对齐。
>
> **实现位置**[`03_ios_app_plan.md`](03_ios_app_plan.md) §8.2 ATT 章节(结论已对齐,本契约把「何时切换」的判定条件落到纸面)+ 本文 §9 第三方 SDK 清单
### P-6 — 未成年用户保护与年龄确认
> **决策**:✅ **App Store 分级标 17+;首次启动弹「我已年满 13 岁」单按钮确认;不收集出生日期;不区分 13–17 岁与 18+**。Web 端在 `/signup` 同步要求勾选。
>
> **理由**:① App Store 17+ 与 Google Play Mature 17+ 是 UGC 平台的行业标准(小红书/Reddit/Discord 均如此);② 不收集出生日期是 PR-2 的强约束——出生日期是高度敏感的可识别信息,收集即增加合规面;③ 「13 岁阈值」对齐 COPPA(美国)+ GDPR-K(欧盟)+ 中国《未成年人网络保护条例》共识下限;④ 不细分 13–17 / 18+ 是因为本平台无年龄分级内容(NSFW 已在 [`10_governance.md`](10_governance.md) §4 处罚阶梯 L4 永久封禁),无需做年龄分流。
>
> **如未来上线 NSFW 分区或商业化**(均为 P2):需追加「证件认证 18+」流程并升级本节为「双闸口」。
>
> **实现位置**[`03_ios_app_plan.md`](03_ios_app_plan.md) §1.2 Auth 页(新增年龄确认 modal+ [`04_web_app_plan.md`](04_web_app_plan.md) R-09 `/signup`(同款 checkbox+ App Store Connect 分级配置(不在代码库)
---
## 5. Web 移交的隐私强相关契约(P-W-1 / P-W-2 / P-W-7
### P-W-1 — viewState 分享链接是否会泄露隐藏几何
> **决策**:✅ **不会泄露,且本决策由数据结构强保证**viewState token 只编码「4 层可见性 + 相机 + overlay_id」三类纯标量字段,**不持有几何数据**([`04_web_app_plan.md`](04_web_app_plan.md) §4.4 + §9.3 已定义编码契约);第三方拿到 `?vs=...` 之后只能改回「打开 furniture 层」这种纯本地切换,无法获得任何未公开的 mesh 顶点或贴图。
>
> **理由**:① 几何始终在 CDN 上的 `canonical.glb` 里,谁能访问 `.glb` 与 viewState 完全无关——若房间是 `private`CDN 路径压根不会公开;若是 `unlisted`/`public`,几何本身已被作者授权公开,「隐藏图层」只是一种**展示偏好**而非**访问控制**。② 这与 ArcGIS 的「图层可见性」语义一致:图层隐藏 ≠ 数据保密。③ 在产品文案上必须明确这一点(即「保存为视图」按钮旁加 tooltip:「这是一种展示视图,不会让别人看不到底图」),避免用户产生「隐藏 = 加密」的错觉。
>
> **实现位置**[`04_web_app_plan.md`](04_web_app_plan.md) §4.4 Named Viewstooltip 文案待补)+ §9.3 viewState 编码契约(仅标量字段已明确)
### P-W-2 — `unlisted` 房间的 OG 卡片 SEO 抓取
> **决策**:✅ **`unlisted` 房间在 `robots.txt` 与 OG endpoint 双重阻断爬虫;但持链访问仍可正常出 OG 图**——「持链可见」是 unlisted 的产品语义,「搜索引擎可索引」不是。
>
> 具体规则:
>
> | 资源 | `public` | `unlisted` | `private` |
> |------|----------|------------|-----------|
> | `robots.txt` 允许 | ✅ | ❌ `Disallow: /r/{id}` 通过动态生成(pg_cron 每小时刷新一次列表) | ❌ |
> | `/r/{id}` 详情页 | SSR 渲染 | SSR 渲染,但响应头 `X-Robots-Tag: noindex, nofollow` | 401 |
> | `/api/og/r/{id}` | 缓存 24h | **仅当 Referer 来自微信/Twitter/Telegram/iMessage UA 名单时返回** OG 图,否则 403 | 403 |
> | sitemap.xml 包含 | ✅ | ❌ | ❌ |
>
> **理由**:① unlisted 的产品语义是「不在 Feed 出现,但持链可看」(与 YouTube Unlisted 一致),SEO 索引会破坏这条承诺;② OG endpoint 的 UA/Referer 白名单是工程性兜底——主流社交平台抓 OG 时都会带可识别 UA,搜索引擎不在名单内;③ 完全屏蔽 OG 会让 unlisted 链接在微信/Twitter 卡片里变成「裸链」,损失分享体验。
>
> **实现位置**[`04_web_app_plan.md`](04_web_app_plan.md) §9.1 OG 标签(需追加 `X-Robots-Tag` 与 UA 白名单逻辑)+ §9.4 robots.txt(追加动态 unlisted 黑名单)
### P-W-7 — viewState token 用作画像的合规风险
> **决策**:✅ **viewState token 一律视为「用户偏好数据」纳入隐私范围管理;禁止将其与 `user_id` 或 IP 关联落库做画像;PostHog 事件中如携带 viewState,必须先经过 hash + 截断**
>
> 具体规则:
>
> 1. 服务端持久化:`view_states` 表(如未来引入)**只**存 `room_id + creator_id + token + created_at`**不存** `viewer_id`/`ip`TTL 90 天后 pg_cron 物理删除
> 2. 前端遥测:PostHog `capture('view_state_loaded', {...})` 事件中 viewState 字段必须用 `sha256(token).slice(0,8)` 替代,且事件本身不带 `user_id`PostHog 项目配置 `enable_recording_console_log: false` + `disable_session_recording: true`
> 3. Sentry breadcrumbviewState token 进入 URL 时自动经过 Sentry `beforeBreadcrumb` 过滤,替换为 `?vs=[REDACTED]`
> 4. 用户导出数据时(§6 合规清单),viewState 历史**不导出**——它是「派生数据」而非「用户数据」
>
> **理由**:① viewState 含相机角度+图层组合,长期累积可推断「这个用户偏好俯视墙体而非家具特写」这类风格画像,进而推荐广告——这与 PR-2 「最小数据采集」冲突;② hash 截断让运营仍能统计「最热门 view 形态」聚合指标,但无法回查到具体用户;③ Sentry 过滤是行业标配(避免 token 进入崩溃报告被工程师肉眼看到)。
>
> **实现位置**[`04_web_app_plan.md`](04_web_app_plan.md) §9.3 viewState 编码契约(追加「派生数据」标识)+ 本文 §9 第三方 SDK 清单 PostHog 一行
---
## 6. GDPR / PIPL 合规清单
> 区分「**MVP 必做**」与「**P2 可推**」,所有必做项需在 8 周窗口内随产品同步上线。
### 6.1 MVP 必做项
| # | 项 | 落地形态 | 关联法条 |
|---|-----|---------|---------|
| C-1 | **隐私政策**zh + en | `/privacy` SSG MDX 页([`04_web_app_plan.md`](04_web_app_plan.md) R-13),首次启动 App 全屏强制阅读 + 勾选 | GDPR Art. 13 / PIPL §17 |
| C-2 | **用户协议(ToS** | `/terms` SSG MDX 页,与 C-1 同弹窗勾选 | 通用 |
| C-3 | **Cookie 通知** | Web 端首次访问底部 banner,区分「必要 / 偏好 / 分析」三类,分析类(PostHog)默认**关**,用户主动 opt-in | GDPR ePrivacy Directive |
| C-4 | **数据导出 API** | 新 Edge Function `account-export`60 秒内异步生成 `.zip`(含用户上传的 .usdz + .json + 评论 + 点赞流水 + 个人档案),通过邮件单次签名链接送达 | GDPR Art. 20 / PIPL §45 |
| C-5 | **删除账户 API** | §4 P-4 决策中的 4 阶段流程 | GDPR Art. 17 / PIPL §47 |
| C-6 | **DPO 联系邮箱** | `privacy@crowdroom.app` 监控信箱,72 小时内首响应 SLA;隐私政策页固定展示 | GDPR Art. 37 / PIPL §52 |
| C-7 | **数据处理记录(RoPA** | 内部 Notion 文档(不公开),记录每个数据类别、用途、保留期、第三方共享 | GDPR Art. 30 |
| C-8 | **未成年人保护** | §4 P-6 决策 | COPPA / 中国《未成年人网络保护条例》 |
### 6.2 P2 可推项(按市场扩张时序推进)
| # | 项 | 触发条件 |
|---|-----|---------|
| C-P2-1 | **DPIA(数据保护影响评估)** | DAU > 10 万 或 进入欧盟主动运营 |
| C-P2-2 | **跨境传输备案(中国 PIPL** | 在中国大陆托管或服务 > 10 万中国用户 → 需走「标准合同 + 网信办备案」 |
| C-P2-3 | **GDPR 代表(欧盟代表)** | 开放欧盟市场或欧盟用户 > 5% MAU → 委托第三方法务公司 |
| C-P2-4 | **SCC(标准合同条款)签订** | 与 Supabase / Sentry / PostHog / Vercel 任一签订 SCC 模块化条款(目前各家官网都有标准模板) |
| C-P2-5 | **CCPA / CPRA 合规**(加州) | 美国市场 DAU > 1 万 |
| C-P2-6 | **App Privacy Manifest**Apple 2024 强制) | iOS 17.4+ 起 App Store 审核必须项;MVP 也要在发版前打钩,本项**已升 MVP**——见 [`03_ios_app_plan.md`](03_ios_app_plan.md) §8 待补 |
> **注**C-P2-6 实际是 Apple 平台强制项(2024 春已生效),MVP 发版必须提交 `PrivacyInfo.xcprivacy` 声明 SDK 清单与 API 使用类别——已 cross-ref 到本文 §9。这是本子任务**反向回写**到 iOS 计划的一处「漏洞」(见 attempt_completion 漏洞列表)。
---
## 7. 隐私默认值清单
> 所有「涉及隐私的开关」MVP 默认值;遵守 PR-3 「默认朝隐私最严方向」与 PR-4 「默认私有」。在「我的 → 设置 → 隐私」一处可视化展示并一键切换。
| 开关 | 默认 | 用户可改 | 说明 |
|------|------|--------|------|
| 房间可见性(新扫描) | **私有** | ✅ | 上传表单必须显式勾选「公开」/「持链可见」,零误公开(PR-4) |
| 位置打标 | **关** | ✅ | 关闭时所有未来上传 `location_label = NULL`(§3.3 |
| 扫描音频录制 | **永关** | ❌ | RoomPlan 不需要音频,`NSMicrophoneUsageDescription` 不申请 |
| 端侧脱敏 | **永开** | ❌ | iOS-X1 硬约束,用户无法关闭(§4 P-1) |
| 保留脱敏前原图本地备份 | **关** | ✅ | 仅当用户主动勾选才在 Storage `pre_redaction.jpg` 留底([`01_data_schema.md`](01_data_schema.md) §6 Storage 目录) |
| Sign in with Apple 隐藏邮箱 | **开**(由 Apple 默认) | ✅ | 与 Apple 私有中继邮箱兼容 |
| Remix 通知(我的房间被 Remix 时通知我) | **开** | ✅ | 创作互动需要,但用户可关 |
| 评论通知 | **开** | ✅ | 同上 |
| 点赞通知 | **关** | ✅ | 默认关,避免高频骚扰 |
| 在公开页面展示我的 handle | **开** | ❌ | handle 是公开身份;不允许「匿名上传」(避免 UGC 治理失控) |
| 在公开页面展示我的 email | **永关** | ❌ | email 永远只对自己可见,RLS 保证([`01_data_schema.md`](01_data_schema.md) §3.1 users 表) |
| PostHog 行为分析 | **关**C-3 Cookie 通知中 opt-in | ✅ | 用户不 opt-in 时 PostHog SDK **不初始化** |
| Sentry 崩溃上报 | **开** | ✅ | 崩溃报告默认不含 PII,但用户仍可在隐私设置关闭 |
| iframe 嵌入我的房间 | **开**(公开/unlisted 房间默认允许嵌入) | ✅ | 关闭后 `/embed/r/{id}` 返回 403,详见 [`10_governance.md`](10_governance.md) §4 P-W-6 |
| 允许搜索引擎索引我的主页 | **开**(仅 public 房间) | ✅ | unlisted/private 永不被索引(§5 P-W-2 |
| ATT(跨 App 跟踪) | **不申请** | ❌ | §4 P-5 决策 |
---
## 8. 数据保留与删除时间表
| 数据类别 | 保留期 | 删除触发 | 是否可恢复 |
|---------|--------|---------|-----------|
| **原始 .usdz / .roomplan.json**(私有 bucket | 与 `room_versions` 同生命周期,最多 90 天 | 90 天后 pg_cron 把 `private/` 转码已成功的版本归档清除(保留 `canonical.glb` 即可服务) | ❌ |
| **`canonical.glb` / `thumbnail.webp`**(公共 bucket | 与 `room_versions` 同 | 房间硬删 cascade;用户注销 T+30 硬删 | ❌ |
| **`redactions[]` 表行** | 与 `room_versions` 同 cascade | 同上 | ❌ |
| **`rooms` / `room_versions` / `layers`** | 永久(除非用户删除) | 用户主动删除 / 注销 T+30 / 严重违规封禁 | T+7 内有效;T+30 后 ❌ |
| **`comments` / `likes`** | 永久 | 评论/点赞作者主动删;房间 cascade 删 | ❌ |
| **`remixes`**(已发表) | 永久 | Remix 作者主动删;父房间走 P-W-3 快照转移流([`10_governance.md`](10_governance.md) §4 | ❌ |
| **草稿 Remix`is_public=false`** | 30 天未更新自动清理 | pg_cron 扫 `updated_at < now() - 30d` | ❌ |
| **`location_label`**(独立字段) | 跟随 `rooms` | 用户可单独 UPDATE NULL(§3.3 | ❌ |
| **审计日志(Edge Function logs** | 90 天 | Supabase 平台默认 | ❌ |
| **Sentry 事件** | 90 天 | Sentry 项目 retention 配置 | ❌ |
| **PostHog 事件** | 7 年(默认)→ **调整为 13 个月** | PostHog project setting 强制下调 | ❌ |
| **Worker 日志(`private/.../transcode.log`** | 30 天 | pg_cron 扫 Storage 元数据 | ❌ |
| **`view_states` 表(若引入)** | 90 天 | pg_cron | ❌ |
| **`reports` 举报记录** | 永久(已结案 6 个月后归档为只读) | 不删除(治理需要追溯) | 仅 owner-team 可见 |
| **用户账号注销** | T+0 软删 / T+7 不可撤 / T+30 硬删 | §4 P-4 流程 | T+0T+7 ✅,之后 ❌ |
---
## 9. 第三方 SDK 风险清单
| SDK | 拿到什么数据 | 数据出域吗 | 是否 GDPR 友好 | SCC 状态 | 我们的额外动作 |
|-----|------------|----------|--------------|---------|--------------|
| **Supabase**Postgres / Auth / Storage / Realtime | 全部业务数据(DB 行 + 文件 + 鉴权 token | 是(其 AWS us-east-1 主机房) | ✅ 官方有 GDPR DPA + SCC | ✅ MVP 即签 | 启用 Supabase Vault 加密 service_role;按 §8 保留期清理 |
| **Sentry**iOS + Browser + Server | 崩溃堆栈 + breadcrumb + 用户邮箱(仅 issue 关联) | 是(Sentry SaaS | ✅ SOC 2 + GDPR DPA | ✅ MVP 即签 | `beforeSend` 过滤 PIIbreadcrumb 中 viewState 替换为 `[REDACTED]`(§5 P-W-7 |
| **PostHog Cloud**(事件分析) | 用户事件 + funnel + feature flag 评估 | 是(PostHog EU / US 双区可选) | ✅ 选 EU 区即数据不离欧 | ✅ MVP 即签 | 强制 opt-inC-3 Cookie 通知);关闭 Session Replay;保留期下调至 13 个月 |
| **CloudFlare**(CDN + DNS) | 公开静态资源访问日志(IP + UA) | 是(全球边缘节点) | ✅ GDPR DPA | ✅ MVP 即签 | 不缓存私有 bucket;启用 CF Bot Fight Mode 防爬 |
| **Vercel**Web 部署 + Edge Function | 请求 IP + UA + 路径 | 是(全球边缘) | ✅ GDPR DPA | ✅ MVP 即签 | Edge Function 内禁用 `console.log` 用户级数据;分析数据保留期默认 |
| **Apple Sign in with Apple** | Apple ID 关联 token | 否(Apple 直接给 token,不持有原始 Apple ID | ✅ Apple 平台原生 | n/a | 接受 Apple Hidden Email 默认行为 |
| **Google OAuth**(仅 Web `/login` | Google 用户 sub + 邮箱 | 否(OAuth 标准 token 交换) | ✅ Google Workspace DPA 适用 | ✅ 通过 Supabase Auth 中转,无直接合同 |
| **MetricKit**(iOS 系统) | 设备性能指标(CPU/GPU/热量) | 否(仅 App 内消费) | ✅ Apple 原生 | n/a | — |
> **底线**:除上表 8 项外,**MVP 不接任何第三方 SDK**。若运营提出新接入需求(如客服系统 Intercom、推送 OneSignal),必须先把该 SDK 加入本表并签 SCC 才可上线——这是 PR-2 的硬执行点。
---
## 10. 本章小结
| 关键产出 | 一句话 |
|----------|--------|
| **5 条隐私原则 PR-1 ~ PR-5** | 端侧优先脱敏 / 最小采集 / 用户可控 / 默认私有 / 可被遗忘——任何冲突按编号优先 |
| **数据流隐私视图** | 红黄绿三色 + 第三方可访问性双标签,红色节点永不离设备/Supabase 内网 |
| **三类敏感数据生命周期** | 人脸端侧不可逆模糊 + 地标/文档手动框选 + 位置永远城市级 5 km |
| **6 条 iOS 契约 P-1 ~ P-6** | 端侧失败不破例 / redactions owner-only / 城市标签公开 / 30 天硬删 / ATT 触发条件 3 项 / 13+ 单按钮确认 |
| **3 条 Web 契约 P-W-1 / P-W-2 / P-W-7** | viewState 不泄漏几何 / unlisted 双重 SEO 阻断 / viewState 视作偏好数据强 hash |
| **GDPR + PIPL 合规清单** | 8 项 MVP 必做(含 Apple PrivacyManifest+ 6 项 P2 |
| **隐私默认值表** | 16 项开关,全部朝最严方向;端侧脱敏与 mic 关、email 不公开 = 永不可改 |
| **数据保留时间表** | 15 类数据,原始 .usdz 90 天 / Sentry-PostHog 调至 13 个月 / 注销 30 天硬删 |
| **8 个第三方 SDK 风险点** | 全数有 GDPR DPA 与 SCC 模板可签;不允许 MVP 期内新增 |
读完本章你应能:
- ✅ 给隐私律师一份可直接审阅的数据处理映射(§2 数据流图 + §3 生命周期 + §9 SDK)
- ✅ 给 iOS / Web 工程师 P-1 ~ P-6、P-W-1 / P-W-2 / P-W-7 共 9 条契约的拍板答案
- ✅ 知道 [`10_governance.md`](10_governance.md) 在哪几节继续处理治理类 P-W-3 ~ P-W-6
---
**章节版本**v0.1 · 草案
**关键收获**:CrowdRoom 把隐私风险压缩到「**用户主动框选发布**」这一个决策点上——前置的端侧脱敏让原始 RGB 永不出设备、后置的 4 层 manifest 让公开数据只剩几何与材质;GDPR + PIPL 合规以「**MVP 必做 8 项 + 第三方 SDK 全签 SCC**」最小集即可上线。
+491
View File
@@ -0,0 +1,491 @@
# CrowdRoom · 治理与社区规则(v0.1)
> 本章承接 [`00_overview.md`](00_overview.md) §8 风险 RK-3(UGC 审核),并对 [`04_web_app_plan.md`](04_web_app_plan.md) §13 移交的治理类契约 **P-W-3 ~ P-W-6** 逐一拍板。隐私类契约(P-1 ~ P-6 与 P-W-1/2/7)见 [`09_privacy.md`](09_privacy.md)。
>
> 本章不重复 [`02_api_contract.md`](02_api_contract.md) §2 E-14 举报端点的请求/响应格式,所有「实现位置」均回指既有章节。
>
> **本章核心论断**CrowdRoom 治理走「**默认透明 + 自动隐藏 + 人工兜底**」三件套,把审核成本压到 MVP 阶段 1 个兼职 reviewer 可承担的水位;最尖锐的「父房间被作者硬删后 Remix 何去何从」冲突,本章以「**硬删时父几何快照转移到 Remix**」拍板,保护 Remixer 既得权。
---
## 1. 治理总原则
| # | 原则 | 一句话定义 | 落地证据 |
|---|------|-----------|---------|
| **GR-1** | **默认透明** | 所有治理决策(隐藏、限流、封禁)必须给被处罚用户一份**结构化通知**:违反哪条规则、谁判定、何时生效、如何申诉。不允许「悄悄降权」。 | §8 申诉流程 + §6 处罚阶梯 |
| **GR-2** | **社区自治优先,平台兜底** | 优先用「自动审核 + 用户举报 + 信任分」放大社区自我治理能力;人工 reviewer 只在自动判定失败或申诉时介入。 | §2 审核流程图 + §3 团队规模 |
| **GR-3** | **升级机制清晰** | 处罚必须分级(警告 → 限流 → 禁言 → 封禁),跳级处罚仅允许在「严重违规」白名单内(NSFW 含未成年 / 暴力威胁等)。 | §6 处罚阶梯表 |
| **GR-4** | **创作者既得权 vs 原作者撤回权的平衡** | 已发表 Remix 是 Remixer 的独立创作产物,原作者的「撤回权」止于「下架自己的原作品 + 自动转移最后一个公开快照到 Remix」,不能让 Remix 跟着消失。 | §4 P-W-3 决策 + §7 创作者权益 |
> GR-4 是本章最具争议的拍板项。它的本质是:CrowdRoom 把房间视作「**公共创作品**」而非「**作者私有数字资产**」——一旦作者把房间设为 `public` 并被 Remix 采纳,作者保留**作品归属权**与**下架原作权**,但放弃了**让所有衍生作品同步消失的权力**。这与 GitHub fork 模型一致,与小红书「删笔记 = 删评论」不一致——CrowdRoom 站队 GitHub。
---
## 2. UGC 内容审核流程
### 2.1 上传 / Remix 发布的自动审核
```mermaid
sequenceDiagram
autonumber
participant U as 用户
participant App as iOS App / Web
participant E as Edge Function
participant W as Transcode Worker
participant Mod as Auto Moderator
participant Q as 人工 review 队列
participant R as Reviewer
U->>App: 提交上传 或 Publish Remix
App->>E: POST upload-complete 或 remix-publish
E->>W: enqueue 转码
W->>W: 转码完成 生成 thumbnail webp
W->>Mod: POST 自动审核 thumbnail title description tags
Mod->>Mod: NSFW 图像分类器对 thumbnail
Mod->>Mod: 敏感词匹配 title description tags
alt 全部通过
Mod-->>E: pass
E->>App: status ready 房间可见
else NSFW 命中 且 score 大于等于 0 9
Mod-->>E: hard_block
E->>App: 房间被 自动下架 通知作者 + 工单进入严重违规队列
else 任一信号低置信 0 5 0 9
Mod-->>Q: 进入人工 review 队列
E->>App: status ready 但 标 under_review 仅作者可见
R->>Q: 24 小时内 review
R->>E: PATCH approve 或 reject
E->>App: 公开 或 永久下架并通知
end
```
| 自动信号 | 工具 | 阈值 | 触发动作 |
|---------|------|------|---------|
| NSFW 图像分类 | NSFWJS(开源 mobilenet 模型,跑在 Edge 上 cold start 12 s | `score >= 0.9` | **硬下架**(无需人工) |
| NSFW 图像分类 | 同上 | `0.5 <= score < 0.9` | 进人工队列 |
| 敏感词(中文 + 英文) | [sensitive-word](https://github.com/houbb/sensitive-word) DFA 词库 + 自维护补丁(政治、暴恐、毒品) | 命中任一 | 进人工队列;命中「儿童相关 + NSFW 词」立即硬下架 |
| 标签数 > 8 | Edge Function 校验 | 直接拒收(业务错误码 `INVALID_TAGS` |
| 重复内容(与现有公开房间 SHA256 一致) | Worker 在转码后比对 `canonical.glb` 哈希 | 命中 | 标 `duplicate_of` 字段(不下架,但发现页降权) |
### 2.2 用户举报的处理流程
```mermaid
stateDiagram-v2
[*] --> Visible: 内容已发布
Visible --> Visible: 0 1 个举报 不动作
Visible --> Hidden: 3 个独立举报 自动隐藏
Visible --> HardDown: 严重违规白名单 立即下架
Hidden --> Reviewing: 自动入人工队列
Reviewing --> Visible: Reviewer 判 误报 恢复
Reviewing --> HardDown: Reviewer 判 违规 永久下架
HardDown --> Appealing: 作者发起申诉
Appealing --> HardDown: 申诉驳回 终审
Appealing --> Visible: 申诉成立 恢复
HardDown --> [*]: 30 天后 数据进入注销硬删流
```
**关键决策点**
| 决策点 | 拍板 | 理由 |
|--------|------|------|
| 自动隐藏阈值 | **3 个独立举报**(同 user_id 重复举报只算 1 次;同 IP 24h 内多次只算 1 次) | 行业经验值(Reddit/Discord 均为 35),低于 3 易被恶意 brigading,高于 5 反应过慢 |
| 「严重违规白名单」 | 儿童不适内容 / 暴恐威胁 / 实名隐私曝光 / 仿冒平台或他人账号 | 这 4 类一经举报立即下架,绕过 3 次阈值——降低司法风险 |
| 自动隐藏期内的可见性 | 作者本人可见(标红「内容已暂时隐藏」),其他用户不可见 | GR-1 透明原则;让作者知情而非被静默处理 |
| 人工 SLA | **24 小时**(从工单进队列到 reviewer 判定) | 与 §3 团队规模匹配;超时则自动恢复显示 + 工单升级到 owner-team |
| 申诉窗口 | 下架后 14 天内可申诉 1 次 | 给冷静期;超 14 天进入终审归档 |
### 2.3 评论与用户层面的处理
| 对象 | 自动信号 | 处罚 |
|------|---------|------|
| 评论(`comments` 表) | 敏感词命中 / 长度异常(< 2 或 > 1000 已数据库约束)/ 30 秒内 > 3 条 | 自动隐藏 + 作者收警告(首次) |
| 用户档案(handle / bio | handle 含敏感词或仿冒平台命名(如 `admin``crowdroom_official`) | 注册时直接拒;事后发现走人工下架 + 强制改名 |
| 用户行为 | 1 小时内被 ≥ 5 人举报且分散在 ≥ 3 个内容上 | 触发账号级人工 review(不立即处罚,只是把工单优先级提升到 P0) |
---
## 3. 审核团队规模与 SLA 预算
### 3.1 MVP 阶段(8 周窗口)
| 角色 | 配置 | 工时预算 | 工具 |
|------|------|---------|------|
| **兼职 Reviewer** ×1 | 项目组成员轮值 | 每日 2 小时(约工单 30–50 条/日上限) | Supabase Studio + 内部 Next.js Admin 页(`/admin/reports`,仅 owner-team 可登) |
| **Owner-team 终审委员会** | 项目方 2 人 | 每周 1 小时 round-up | 同上 |
| **DPO(兼隐私事件)** | 1 人 | `privacy@crowdroom.app` 监控 | 邮箱 + Notion |
**SLA**
| 类目 | 首响应 | 处置完成 |
|------|--------|---------|
| 普通举报 | 24 h | 48 h |
| 严重违规(白名单) | 自动下架即时 | 人工复核 12 h |
| 隐私事件(DPO | 72 h | 视个案 |
| 申诉 | 5 工作日 | 14 工作日 |
### 3.2 P1 阶段(DAU > 5 000 起)
- 引入「**信任分 + 志愿者审核团**」:
- 用户初始信任分 50;每次成功举报(被 reviewer 判定为有效)+5;每次被举报且判定有效 −20;满 100 分可申请加入「志愿者审核团」
- 志愿者投票多数(5 选 3 通过)等价于 1 个 reviewer 判定,可绕过 24 h SLA
- 信任分公开(用户主页可展示徽章),但具体数值仅自己可见
- 引入「**自动学习敏感词补丁**」:reviewer 拒绝某关键词 ≥ 5 次后自动加入候选库,等待 owner-team 确认
### 3.3 P2 阶段(DAU > 50 000
- 全职 Trust & Safety 团队 ≥ 2 人
- 接入第三方审核服务(如 Hive Moderation / Microsoft Content Moderator)做兜底
- DPIA 评估(见 [`09_privacy.md`](09_privacy.md) §6.2 C-P2-1
---
## 4. Web 移交契约(P-W-3 ~ P-W-6)逐条决策
### P-W-3 — 父房间硬删后 Remix 的继承策略
> **决策**:✅ **父房间硬删时,平台自动把父房间「最后一个 `ready` 公开版本」的几何快照(`canonical.glb` + `layer_manifest.json` + `redactions[]`)转移到引用该版本的所有公开 Remix 的独立目录下;Remix 继续可见,但署名变为「fork from former user」**。这是 GR-4 的最尖锐落地点。
>
> 具体规则:
>
> | 父房间动作 | 父几何 | 已发表公开 Remix(`is_public=true` | 草稿 Remix`is_public=false` |
> |-----------|--------|------------------------------------|-------------------------------|
> | **作者「设为私有」** | 私有 bucket 保留 | 继续渲染(公共 bucket 缓存仍有效);但 `/r/{parent_id}` 跳父房间会 401 | 草稿编辑器抓 `REMIX_PARENT_DELETED` → 提示用户「另存独立副本」 |
> | **作者「软删除」**(默认删除按钮) | 标 `deleted_at`,30 天保留 | 同上 + 父名称显示为「Former room」+ 跳链 disabled | 同上 |
> | **作者「立即硬删」**(隐藏的高级选项) | **触发快照转移**:把父 `canonical.glb / layer_manifest.json / redactions[]` 复制到 `public/remix-fallbacks/{parent_room_id}/`,所有引用该父版本的 `remixes``parent_snapshot_path` 字段指向新路径;之后父原路径硬删 | 继续渲染,URL 不变;详情页顶部显示「原始作者已注销/移除 · 此版本由 CrowdRoom 保留作为 fork 基础」;署名行原作者变为「Former CrowdRoom user」 | 同上(独立副本) |
> | **账号注销 T+30 硬删**[`09_privacy.md`](09_privacy.md) §4 P-4) | 同「立即硬删」分支 | 同上 | 同上 |
>
> **理由**
>
> 1. **Remixer 既得权**Remix 一经公开,就是 Remixer 的独立创作产物(含 overlay + 选择 + 描述),让父作者「一键带走全社区的衍生作品」违反 GR-4。GitHub fork、Tumblr reblog、Twitter retweet 在原帖删除后均保留衍生内容,CrowdRoom 站队这一行业惯例。
> 2. **原作者的"展示完毕"尊严**:原作者已经把作品公开过、被赞过、被 Remix 过,社会语义上他/她已经「获得了发表的尊严」,删除原作时主要诉求是「我不想继续署名」而非「让所有衍生消失」——后者属于过度索权。署名替换为「Former CrowdRoom user」即满足前者。
> 3. **存储成本可控**:单 Remix 复制 5 MB `canonical.glb` + 50 KB manifest,假设 MVP 期 1 万房间 × 平均 0.3 个 Remix × 5 MB ≈ 15 GBSupabase Storage 月成本 < $5。
> 4. **法律风险**:通过 ToS 第 4 章「内容授权」条款(§7 创作者权益)让用户在上传时签署 CC BY-NC 4.0 授权,允许平台在原账号注销后继续承载衍生作品。这条 ToS 条款必须在 [`09_privacy.md`](09_privacy.md) §6 C-2 同步落地。
> 5. **隐私冲突**:父房间含 `redactions[]` 含被脱敏的人脸 bbox——快照中**只**复制 `canonical.glb`(已模糊)与 `layer_manifest.json`**不**复制 `redactions[]` 表行(避免在原作者注销后还保留其脱敏审计数据)。这是隐私 P-2 + 治理 P-W-3 的边界对齐。
>
> **如何告知用户**iOS App 与 Web 端的「删除房间」按钮旁固定 warning:「该房间有 N 个公开 Remix。删除后这些 Remix 仍会保留,但你的署名会被移除。」用户必须勾选确认才可继续。
>
> **实现位置**
> - [`01_data_schema.md`](01_data_schema.md) §3.6 `remixes` 表追加 `parent_snapshot_path TEXT NULL` 字段(在 v0.2 schema 中补)
> - [`02_api_contract.md`](02_api_contract.md) 新增 Edge Function `room-delete-with-snapshot`(v0.2 补 E-16,逻辑:复制快照 → 更新 remixes 行 → 删父)
> - [`04_web_app_plan.md`](04_web_app_plan.md) §7.2 已抓 `REMIX_PARENT_DELETED`,文案与本决策对齐
> - 本章 §7 创作者权益 + ToS 4 章「内容授权」
### P-W-4 — 公共资产库协议白名单
> **决策**:✅ **MVP 全 CC0(无署名要求);P1 开放 CC-BY 4.0(要求 attribution);CC-BY-NC / CC-BY-SA / 付费素材推到 P2**
>
> 具体规则:
>
> | 协议 | MVP | P1DAU > 5 000 | P2 |
> |------|-----|------------------|-----|
> | **CC0**(公共领域) | ✅ 默认 | ✅ | ✅ |
> | **CC-BY 4.0**(要求署名) | ❌ | ✅ 引入;Remix 编辑器自动追加 `attribution.json` 到 overlay;详情页展示「素材来源」区 | ✅ |
> | **CC-BY-SA**(同等共享) | ❌ | ❌ 与本平台 CC BY-NC 默认协议冲突,永不接 | ❌ |
> | **CC-BY-NC**(非商业) | ❌ | ❌(CrowdRoom 本身默认 CC BY-NC,再加一层 NC 会让 Remix 商业化路径完全堵死) | 视商业模型再议 |
> | **付费素材 / 创作者上传素材** | ❌ | ❌ | P2 引入「素材市场」+ 分成模型 |
>
> **资产入库审核流程**(即便都是 CC0):
>
> 1. 运营/素材管理员通过内部 Admin 页批量导入(来源:Poly Pizza、ambientCG、Polyhaven,全为 CC0
> 2. 自动跑 NSFW 分类(同 §2.1 阈值)+ 几何完整性检查(vertex count、texture 完整性)
> 3. 入库后默认 `is_public=false`owner-team 二次抽查后改 `true`
>
> **理由**:① MVP 全 CC0 把版权审查负担降到 0([`04_web_app_plan.md`](04_web_app_plan.md) §5.4 已硬过滤);② CC-BY 在 P1 引入是因为 Polyhaven 等优质资产库主要走 CC-BY,不接等于放弃 80% 优质素材池;③ CC-BY-SA 不接是因为其「衍生作品必须同协议」会污染整个 Remix 树;④ 付费素材推 P2 是因为分成模型涉及税务、对账、退款 —— 超 MVP 预算。
>
> **实现位置**[`04_web_app_plan.md`](04_web_app_plan.md) §5.4 顶部「仅显示 CC0」开关 + [`01_data_schema.md`](01_data_schema.md) §3.9 `assets.license` 字段约束 + 本章 §7 版权与署名规则
### P-W-5 — 举报按钮 E-14 可达性
> **决策**:✅ **所有 UGC 显示位置必须 ≤ 2 次点击可触达举报弹层;举报按钮在视觉上不能比「点赞」更弱**
>
> 具体落地:
>
> | 显示位置 | 举报按钮位置 | 点击次数 |
> |---------|------------|---------|
> | 房间详情页 `/r/[room_id]` | 顶栏 ⋯ 菜单第一项 | 2(点 ⋯ → 点举报) |
> | Remix 详情页 `/remix/[remix_id]` | 同上 | 2 |
> | 评论行(`comments`) | 评论行右侧 ⋯ 第一项 | 2 |
> | 用户主页 `/u/[handle]` | 顶部 ⋯ 第一项 | 2 |
> | 资产卡片(`/assets`) | 卡片右下角 ⚠ 图标直接展示 | 1 |
> | Remix 编辑器(编辑某房间时) | 顶部工具栏 ⚠ 图标 | 1 |
> | iframe 嵌入 `/embed/r/[room_id]` | 右下角 "Report" 文字链接(不打 logo | 1 |
> | iOS App 详情页 | 底部 Sheet → ⚠ 举报 第一项 | 2 |
>
> **视觉权重要求**:举报按钮图标尺寸 ≥ 24×24,颜色对比度满足 WCAG AA;不允许藏在「⋯ → 更多 → 更多」三级菜单后。
>
> **未登录用户**:点击举报 → 弹出登录引导(保留举报 intent 到 localStorage,登录后自动回到该弹层)。**不允许匿名举报**(避免 brigading),但保留入口可见。
>
> **理由**:① E-14 是 §2.2 自动隐藏机制的唯一信号源,可达性低则整个治理失血;② 1-2 次点击是行业惯例(YouTube/Reddit 均为 2 次);③ iframe 嵌入版必须可达举报否则恶意嵌入网站可永久承载违规内容;④ 不允许匿名是因为「举报需要追责」是 §6 信任分系统的前提。
>
> **实现位置**[`04_web_app_plan.md`](04_web_app_plan.md) §8.3 顶栏操作 ⚠ 举报(已落 1 次点击至弹层,本决策追加「⋯ 菜单下也保留」)+ §8.5 评论区(追加 ⋯ 菜单)+ R-12 `/embed` 路由(追加右下角 Report 链接)+ [`03_ios_app_plan.md`](03_ios_app_plan.md) §1.2 ReportSheet(已存在)
### P-W-6 — iframe 嵌入的频次/速率限制
> **决策**:✅ **基于 Referer + IP 双键限流;不计入原房间作者流量配额(CDN 流量由平台兜底),但极端滥用者整域永久 ban**
>
> 具体规则:
>
> | 嵌入来源 | 频次 | 触发处置 |
> |---------|------|---------|
> | **未注册 Referer**(任意网站第一次嵌入) | 5 req/min/Referer + 30 req/hour/IP | 超限返回 429 + Retry-After |
> | **注册 Referer**owner 在 `/me/embeds` 主动登记自家域名) | 30 req/min/Referer + 配额按账户档位(free 1 万 req/月、creator 10 万、pro 100 万) | 超档位返回 429 |
> | **嵌入到 NSFW / 违规域名** | 0 | owner-team 维护黑名单 referer,命中直接 403 + 通知房间作者 |
> | **嵌入到自家 `crowdroom.app`**(如博客嵌入自己详情页) | 不限制 | 走站内同源 |
>
> **CDN 流量归属**
>
> - iframe 加载的 `canonical.glb / thumbnail` 走公共 CDN**不计入**房间作者的 Storage 流量(Storage 配额仅算上传字节)
> - CDN 流量费由平台统一承担;MVP 期 CloudFlare 免费档足够(无需 R2 出口费用),DAU > 1 万时迁到 Cloudflare R2 + Vercel Image Optimization 双层缓存
> - 房间作者可在 `/me/embeds` 看到自己被嵌入的 Referer 列表与日访问量(透明展示,不收费);作者可一键封禁某 Referer
>
> **关闭嵌入的能力**
>
> - 在房间详情页「设置 → 嵌入」可一键关闭嵌入(默认开),关闭后 `/embed/r/{id}` 返回 403
> - 这是隐私默认值表([`09_privacy.md`](09_privacy.md) §7)中「iframe 嵌入我的房间」开关的实际后端落地
>
> **理由**:① iframe 嵌入是 CrowdRoom 病毒传播的关键路径,过严限流损失增长;② 限流是 P0 风控刚需——避免广告联盟把 CrowdRoom 当免费 3D 展示组件无限抓取;③ 不计作者配额是「平台兜底基础设施成本,作者只为存储付费」的清晰边界;④ NSFW Referer 黑名单是 §2 治理 + P-W-5 举报联动的下游消费方。
>
> **实现位置**[`04_web_app_plan.md`](04_web_app_plan.md) R-12 `/embed/r/[room_id]` 路由(追加 Referer/IP 限流中间件,可用 Vercel Edge Middleware 或 Upstash Rate Limit+ [`02_api_contract.md`](02_api_contract.md) §7 错误码(追加 `EMBED_RATE_LIMITED` `EMBED_FORBIDDEN`+ 新增 `/me/embeds` Web 子页
---
## 5. 社区规则文档大纲(5 条核心规则)
> 本节是面向用户的「社区准则」(Community Guidelines)的工程版骨架;正式版由法务 + 运营在 [`04_web_app_plan.md`](04_web_app_plan.md) R-13 `/legal` 页编辑 MDX。每条规则含:1 段解释 + 违规等级映射 + 处罚阶梯(详见 §6)。
### CR-1:禁止上传他人住宅未授权扫描
**解释**CrowdRoom 用于分享你**自己有权处置**的空间——你的家、你租住的房间、得到主人许可的朋友家。**任何未经主人明确授权扫描的他人住宅**(包括 Airbnb 短租房未告知房东、酒店房间含私人物品、商业空间未与运营方协调)均违反本条。
| 违规等级 | 典型行为 | 处罚阶梯 |
|---------|---------|---------|
| L2(限流) | 短租房扫描但未声明授权 | 警告 + 房间设为 unlisted |
| L3(禁言 + 强制下架) | 他人住宅扫描被主人/居住者举报 | 房间硬删 + 7 天禁止上传 |
| L4(永久封禁) | 多次重犯 或 含强制取证(含他人证件、私人通信暴露) | 账号永久封禁 + 全部房间硬删 |
### CR-2:禁止 NSFW / 暴力 / 仇恨内容
**解释**CrowdRoom 是 App Store 17+ 但**非成人平台**。禁止内容包括:成人/裸体内容(含艺术品的露点雕塑;中性的人体素描可豁免)、写实暴力血腥、对个人或群体的仇恨言论与符号(种族、性别、宗教、性取向)、明显宣扬非法行为的场景陈设(吸毒器具特写、武器展柜)。
| 违规等级 | 典型行为 | 处罚阶梯 |
|---------|---------|---------|
| **白名单立即下架** | 任何含未成年的 NSFW | 账号永久封禁 + 必要时报警 |
| L4(永久封禁) | 成人内容 / 仇恨符号 | 房间硬删 + 账号永久封禁 |
| L3(禁言 7 天) | 边缘暴力或暗示性内容 | 房间下架 + 警告 |
| L1(警告) | 模糊的成人暗示标签(用户辩称误标) | 标签清理 + 警告 |
### CR-3:禁止商业广告(非合作伙伴)
**解释**:禁止把房间标题、描述、评论、handle 用作直接商业引流(含但不限于:联系方式、推广短链、二维码贴在贴图上、电商商品页链接)。**例外**:与 CrowdRoom 签约的家居品牌方可走「认证账号 + 公开商业内容」通道(P2 商业化)。
| 违规等级 | 典型行为 | 处罚阶梯 |
|---------|---------|---------|
| L1(警告) | 评论里发软广 | 评论隐藏 + 警告 |
| L2(限流) | 标题/描述含联系方式 | 字段清空 + 房间发现页降权 |
| L3(禁言) | 反复在多个房间植入广告 | 7 天禁言 + 全部含广告内容下架 |
| L4(永久封禁) | 机器化批量推广 | 账号封禁 + IP 段封禁 |
### CR-4:尊重原作者署名(Remix 必须标注 fork from
**解释**:所有 Remix 必须自动且不可隐藏地保留「fork from @原作者 / 房间标题」的署名行(即便原账号已注销,仍显示「Former CrowdRoom user」——见 P-W-3)。禁止:移除署名、在评论/标题中暗示「这是我的原创扫描而非 Remix」、在导出/外发的物料里抹除来源。
| 违规等级 | 典型行为 | 处罚阶梯 |
|---------|---------|---------|
| L2(限流) | 标题暗示「我的原创」 | 强制标题前加 [Remix] 标签 |
| L3(禁言) | 拒不修改并反复操作 | 该 Remix 下架 + 3 天禁言 |
| L4(永久封禁) | 系统性抹除署名 + 商业利用 | 永久封禁 |
### CR-5:不得伪造他人作品(含 AI 仿冒)
**解释**:禁止:用 handle / 头像 / 房间标题模仿其他用户造成混淆;声称某房间是知名设计师/品牌方作品而实际不是;上传 AI 生成的房间扫描却标注为「真实扫描」(CrowdRoom 是「真实空间共享」平台,AI 生成 P2 单独开分区)。
| 违规等级 | 典型行为 | 处罚阶梯 |
|---------|---------|---------|
| L2 | handle 与他人相似 | 强制改名 |
| L3 | 房间冒充他人作品 | 房间下架 + 7 天禁言 |
| L4 | 系统性伪造(含商业目的) | 永久封禁 + 法律追究权保留 |
---
## 6. 处罚阶梯表
> 4 级阶梯 + 「立即下架白名单」越级通道。所有处罚走 GR-1 透明通知(通过 App 内通知 + 注册邮箱双通道)。
| 等级 | 名称 | 行为定义 | 首次处罚 | 重复处罚(90 天内累计 ≥ 2 次同等级) | 申诉机制 |
|------|------|---------|---------|---------|---------|
| **L1** | **警告** | 轻微违规(边缘标签、单条软广评论) | 在线通知 + 内容隐藏 / 字段清理;信任分 −5 | 升级 L2 | 不可申诉(轻量、自动撤销 30 天后清除记录) |
| **L2** | **限流** | 中度违规(短租未授权、标题广告、handle 仿冒) | 内容隐藏;该用户全部公开内容**发现页排序权重 × 0.3** 持续 14 天;信任分 −15 | 升级 L3 | 申诉窗口 14 天(§8) |
| **L3** | **暂时禁言** | 较重违规(多次软广、未经允许扫描他人住宅、抹除署名) | 7 天禁止:上传新房间 / 发布 Remix / 发评论 / 举报;但仍可浏览;信任分 −30 | 升级 L4 | 申诉窗口 14 天 |
| **L4** | **永久封禁** | 严重违规(NSFW、仇恨内容、系统性伪造、批量商业推广) | 账号永久封禁;30 天内所有内容硬删;公共 Remix 走 P-W-3 快照转移流;IP/设备指纹加入黑名单 | — | 申诉窗口 30 天 + 终审委员会复核 |
| **WL** | **立即下架白名单**(越级) | 含未成年 NSFW / 实名隐私曝光 / 明确暴恐威胁 / 仿冒平台账号 | 内容立即硬下架 + 账号永久封禁;视情况上报执法机关 | — | 申诉窗口 7 天(但永不撤销「上报执法」动作) |
**累计与衰减规则**
- L1 警告 30 天内未再触发 → 自动清零(不计入累计)
- L2/L3 处罚记录保留 12 个月,期间无新处罚 → 衰减为 L1
- L4 永久封禁不衰减
- 信任分(§3.2 P1 起启用):跌至 0 自动触发 L2;跌至 −50 自动触发 L3 人工复核
---
## 7. 创作者权利与义务
### 7.1 权利
| 权利 | MVP 范围 | P2 扩展 |
|------|---------|---------|
| **作品归属** | `rooms.owner_id` 字段绑定;UI 顶部固定展示 handle | 上链/可验证证明 |
| **删除权** | 可软删(30 天可恢复)+ 硬删(走 P-W-3 快照流) | — |
| **下架权** | 可随时切换 `visibility=private` | — |
| **转移所有权** | ❌ MVP 不做(避免账号买卖灰产) | ✅ 提供「正式所有权转让」流程(双方邮件+签字) |
| **拒绝 Remix** | 房间设置中可关闭 `allow_remix`(默认开) | — |
| **商业化收益** | ❌ MVP 不做 | ✅ 素材市场分成 / 品牌方合作分成 |
| **被举报时知情权** | 收到举报后 24 h 内通知作者(除非「严重违规白名单」需先下架) | — |
| **申诉权** | §8 流程 | — |
### 7.2 义务
| 义务 | 验证方式 |
|------|---------|
| **原创性声明** | 上传时勾选「我有权处置该空间且扫描内容真实」(ToS 第 3 章) |
| **隐私脱敏责任** | iOS-X1 端侧脱敏强制 + 手动框选机制([`09_privacy.md`](09_privacy.md) §3.2 |
| **举报响应** | 收到平台关于自己作品的举报通知后,7 天内未申诉视为接受处罚 |
| **遵守社区规则** | §5 CR-1 ~ CR-5 |
| **不规避平台机制** | 不得用脚本绕过配额、不得伪造举报、不得用机器人刷点赞 |
---
## 8. 版权与署名规则(CC 协议)
### 8.1 平台默认协议:CC BY-NC 4.0
> 用户上传到 CrowdRoom 的所有公开房间默认遵守 **Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)**
>
> - **BY**:他人 Remix / 引用必须署名(自动 fork-from 标注满足)
> - **NC**:禁止商业使用(含商品图、广告素材、付费课程教材)
>
> 用户在上传表单可选择「升级到 CC BY 4.0」(允许商业使用,仍需署名)或「保留全部权利」(不允许 Remix——`allow_remix=false`)。**默认 CC BY-NC**。
### 8.2 协议对照表
| 协议 | Remix 允许 | 商业允许 | 衍生协议要求 | CrowdRoom 支持 |
|------|-----------|---------|------------|--------------|
| 保留全部权利(All Rights Reserved | ❌ | ❌ | n/a | ✅(上传时可选) |
| **CC BY-NC 4.0** | ✅ | ❌ | 必须署名 | ✅ **默认** |
| CC BY 4.0 | ✅ | ✅ | 必须署名 | ✅(上传时可选升级) |
| CC BY-SA | ✅ | ✅ | 同等共享(衍生必须同协议) | ❌ 永不支持(污染 Remix 树) |
| CC0(公共领域) | ✅ | ✅ | 无 | ✅(仅适用于公共资产库 §P-W-4) |
### 8.3 署名格式
```
原作者 @alice · 房间「我的客厅」· 协议 CC BY-NC 4.0
```
- Remix 详情页顶部固定显示
- OG 卡片下方小字
- iframe 嵌入版右下角
- 用户导出/截图工具自动在角落添加水印(P1)
### 8.4 第三方资产(公共资产库)的协议处理
详见 §4 P-W-4 决策与 [`04_web_app_plan.md`](04_web_app_plan.md) §5.4。
---
## 9. 争议解决与申诉流程
### 9.1 用户与用户之间
```mermaid
sequenceDiagram
autonumber
participant A as 用户 A 被举报方
participant B as 用户 B 举报方
participant Sys as 平台自动系统
participant R as 人工 Reviewer
B->>Sys: 举报 A 的房间 reason detail
Sys->>Sys: 累计第 3 个独立举报 触发自动隐藏
Sys->>A: 通知 你的房间被暂时隐藏 处于 review 中
Sys->>R: 入工单队列 24h SLA
R->>R: 综合 NSFW score 敏感词命中 举报理由 检查内容
alt 判定违规
R->>Sys: reject
Sys->>A: 通知 房间永久下架 等级 L 处罚 申诉窗口 14 天
Sys->>B: 通知 举报成立 信任分 加 5
else 判定误报
R->>Sys: approve
Sys->>A: 通知 内容已恢复
Sys->>B: 通知 举报未成立 信任分 不变 但 第 3 次连续误报 减 5
end
opt 用户 A 申诉
A->>Sys: POST appeals 附详细说明
Sys->>R: 不同 reviewer 二次审查
alt 申诉成立
Sys->>A: 撤销处罚 + 内容恢复 + 信任分回补
else 申诉驳回
Sys->>A: 维持原判 + 提示终审权
end
end
```
### 9.2 用户与平台之间
| 阶段 | 通道 | SLA |
|------|------|-----|
| 1. 在线申诉 | App 内 / Web 内「申诉」入口 → 写明工单号 + 申诉理由(≤ 1000 字) + 可附证据链接 | 5 工作日首响应 |
| 2. 邮件复核 | `appeals@crowdroom.app`(每个工单的处理通知中包含此邮箱) | 7 工作日 |
| 3. 终审委员会 | MVP 阶段 = 项目方 owner-team 2 人 + DPOP2 引入外部委员 1 人(社区代表) | 14 工作日 |
| 4. 法律诉求 | 用户保留按所在国法律走司法途径的权利;平台不在 ToS 中强制仲裁条款(不剥夺用户起诉权) | 视司法管辖区 |
**申诉成立的判定标准**
1. 原举报理由与实际内容不符(误报)
2. 内容已修改并满足规则(如手动框选脱敏后重新提交)
3. 程序瑕疵(如未在 24 h SLA 内首响应、reviewer 利益冲突)
**申诉失败后的最终路径**:保留个人数据导出权([`09_privacy.md`](09_privacy.md) §6 C-4),鼓励迁移到其他平台,不阻止公开吐槽(GR-1)。
---
## 10. 下线 / 退场规范
> 若 CrowdRoom 产品最终下线(融资失败、合并、战略调整),治理义务并不随之消失。本节是面向**未来某一天**的合同性承诺。
| 阶段 | 时间 | 行为 |
|------|------|------|
| **T-90 天** | 决策发布 | 在 `/` 顶部 banner + 注册邮箱通知所有用户「CrowdRoom 将于 X 月 Y 日下线」;同时给媒体一份新闻稿(避免突然消失引发恐慌) |
| **T-90 → T-30** | 60 天导出窗口 | `account-export` API 保持可用且**无限次免费**;新增「批量导出我所有 Remix 来源父房间几何快照」一键功能 |
| **T-30 → T-7** | 只读模式 | 关闭上传 / Remix 发布 / 评论;保留浏览 + 导出 + 删除账号 |
| **T-7 → T-0** | 数据迁移 | 与社区协商接收方(如 Internet Archive、某非盈利艺术机构);所有 `visibility=public` 且协议为 CC0/CC-BY/CC-BY-NC 的房间**继续以 CC0 镜像**对外可访问;用户数据(私有房间、私信、未公开 Remix)一律不迁移 |
| **T+0** | 服务关闭 | 主域名跳转到归档静态镜像;数据库 + 私有 Storage 销毁;归档镜像由接收方运营,CrowdRoom 团队不再有访问权 |
| **T+30** | 数据销毁完成 | 公开销毁证明(含 SHA256 哈希链上锚定,P2 可选) |
> 这条「**公开数据继续 CC0 镜像**」承诺是 CrowdRoom 对社区的最重要长期信用——它让创作者愿意把扫描放上来,因为知道即便平台死了,作品还在。这与 Reddit/Flickr/Instagram 的「关张即归零」形成对照,是本平台差异化的合规底色。
---
## 11. 本章小结
| 关键产出 | 一句话 |
|----------|--------|
| **4 条治理原则 GR-1 ~ GR-4** | 默认透明 / 社区自治优先 / 升级机制清晰 / 创作者既得权 vs 撤回权平衡 |
| **UGC 自动审核流程** | NSFW 分类 + 敏感词 DFA,硬阈 0.9 立即下架,软阈 0.5–0.9 入人工 24 h |
| **举报状态机** | 3 个独立举报自动隐藏 → 人工 24 h → 申诉 14 天;严重违规白名单越级 |
| **MVP 团队规模** | 1 兼职 reviewer + owner-team 终审 + DPO 隐私邮箱;P1 引入信任分 + 志愿者审核团 |
| **4 条治理契约 P-W-3 ~ P-W-6** | 父硬删快照转移到 Remix / MVP 全 CC0 P1 加 CC-BY / 举报 ≤ 2 次点击可达 / iframe Referer+IP 限流不计作者配额 |
| **5 条社区规则 CR-1 ~ CR-5** | 禁未授权他宅扫描 / 禁 NSFW 暴力仇恨 / 禁广告 / 必须 Remix 署名 / 不得伪造他人作品 |
| **4 级处罚阶梯 + 白名单越级** | L1 警告 / L2 限流 / L3 7 天禁言 / L4 永久封禁 / WL 立即下架 |
| **CC BY-NC 4.0 默认协议** | 用户可升级 CC BY 4.0 或保留全部权利;公共资产库 CC0 |
| **3 级申诉流程** | 在线申诉 → 邮件复核 → 终审委员会,保留司法救济权 |
| **退场规范** | 90 天预警 + 公开数据 CC0 镜像永久承诺,对照 Reddit/Flickr 关张归零 |
读完本章你应能:
- ✅ 给运营团队一份 MVP 8 周内可启用的审核 SOP(含工具、阈值、SLA、信任分初值)
- ✅ 给法务一份 ToS / 社区准则 / 申诉条款的结构骨架(再做语言润色即可发布)
- ✅ 解释为什么 CrowdRoom 在「父删 vs Remix 保留」这一争议点上站队 GitHub fork 模型,并对作者诉求做了「署名替换」式补偿
---
**章节版本**v0.1 · 草案
**关键收获**CrowdRoom 治理的灵魂是 GR-4「**创作者既得权 vs 原作者撤回权平衡**」的一次明确拍板——以「快照转移 + 署名替换」化解 P-W-3 的根本矛盾,并配合 CC BY-NC 默认协议、4 级处罚阶梯、90 天退场承诺,把一个社区平台的「长期信用」三件套(**透明 / 公平 / 不可消失**)一次性敲定。
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
# CrowdRoom · 变更日志(CHANGELOG
> 本文件遵循 [Keep a Changelog 1.1.0](https://keepachangelog.com/zh-CN/1.1.0/) 规范,版本号采用 [语义化版本 SemVer 2.0.0](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。
>
> 本日志只记录**已发生**的设计文档变更;未来计划见 [`ROADMAP.md`](ROADMAP.md)。
>
> 版本号含义:
> - **0.x.y** —— 设计阶段(pre-MVP),尚未对外发布代码
> - **0.5.0**(计划) —— M1 MVP 上线,对应 [`ROADMAP.md`](ROADMAP.md) §2.1 M1 DoD
> - **1.0.0**(计划) —— M3 v1.0 上线,对应 ROADMAP §2.3 M3 DoD
>
> 每个版本下按 `Added / Changed / Deprecated / Removed / Fixed / Security / Planned` 分类。
---
## [Unreleased]
> 下个版本(v0.3)的待办:v0.2 文档冻结后由子任务 7([`ROADMAP.md`](ROADMAP.md))汇总识别出的 4 条**跨文档新设计冲突**。M1 W4 之前必须完成分诊(修复 / 推迟 / 拒绝 三选一)。
### Planned
- **C-NEW-1** —— **viewState 编码确定性 vs 强 hash 反画像冲突分诊**
[`04_web_app_plan.md`](04_web_app_plan.md) §9.3 把 viewState 设为「gzip+base64url 确定性编码」(便于 OG 缓存复用),但 [`09_privacy.md`](09_privacy.md) §5 P-W-7 要求 viewState 在 Realtime / 日志路径上作为画像数据进行强 hash —— 「确定性」与「不可逆」直接冲突。
→ 建议方案:「双轨编码」(URL 用确定性、日志用 HMAC-with-rotating-salt)。回写位置:[`02_api_contract.md`](02_api_contract.md) §5 + [`09_privacy.md`](09_privacy.md) §5 P-W-7。
时间窗:**M1 W2 前**。
- **C-NEW-2** —— **E-19(父硬删快照转移)与 E-17(账号注销三阶段)并发未定义**
若用户在 T+0 注销时其房间被他人 Remix,则 E-19 触发时点(T+0 / T+7 / T+30)、失败回滚顺序、对 Remixer 的通知顺序均未在 v0.2 任何文档明确。
→ 回写位置:[`02_api_contract.md`](02_api_contract.md) §3 新增「注销级联管线」+ [`01_data_schema.md`](01_data_schema.md) §3.11 补 E-19/E-17 交互矩阵。
时间窗:**M1 W3 前**。
- **C-NEW-3** —— **位置标签城市级 5 km 的数据流端到端未对齐**
[`09_privacy.md`](09_privacy.md) §3.3 定义「永远城市级 5 km」,[`01_data_schema.md`](01_data_schema.md) §3.2 G-10 已加 `location_label` 字段标注,但 [`03_ios_app_plan.md`](03_ios_app_plan.md) §8.1 `NSLocationWhenInUseUsageDescription` 文案未提「5 km 网格」、且量化发生在客户端还是服务端未定义。
→ 回写位置:[`03_ios_app_plan.md`](03_ios_app_plan.md) §8.1 + [`02_api_contract.md`](02_api_contract.md) E-02 请求体校验规则。
时间窗:**M1 W2 前**(合规底线)。
- **C-NEW-4** —— **信任分系统初始分计算口径缺失**
[`10_governance.md`](10_governance.md) §3.2 提到「P1 引入信任分」但无公式;[`ROADMAP.md`](ROADMAP.md) §2.3 M3 DoD 已写「初始分 50 / 上限 100 / 因子 6 项」,但权重 / 衰减 / 与 ATT / 举报 / 申诉的勾稽未拍板。
→ 回写位置:新增 [`plans/CrowdRoom/11_trust_score.md`](11_trust_score.md) 或并入 [`10_governance.md`](10_governance.md) §3。
时间窗:**M2 W14 前**M3 DoR 倒推)。
> ⚠️ 上述 4 条由 v0.2 文档静态分析推断;若子任务 6 原作者另有 attempt_completion 中明确写明的 C-NEW 原文,请以原作者版本覆盖此区块。
---
## [0.2.0] - 2026-05-19
> **修订主题**v0.1 review 阶段识别出 **10 条 G 漏洞**(G-1 ~ G-10)的全量回写。所有修订严格遵守「**只追加 / 字段插入 / 文案润色,不改既有字段语义、不破坏既有引用**」原则。
### Added
- **G-1** —— [`01_data_schema.md`](01_data_schema.md) §3.6 `remixes` 表新增字段 `parent_snapshot_path`nullable text),用于父房间被作者硬删时承载几何快照转移目标路径;与 [`10_governance.md`](10_governance.md) §4 P-W-3 拍板的「快照转移 + 署名替换」配套。
- **G-2** —— [`02_api_contract.md`](02_api_contract.md) §2.1 新增 3 个 Edge Function 端点:
- `E-17 account-deletion`:账号注销三阶段(T+0 软删 / T+7 不可撤 / T+30 硬删),软封装原 E-16
- `E-18 account-export`:用户数据一键导出(GDPR Art.20 数据可携权)
- `E-19 parent-snapshot-transfer`:父房间硬删时把几何快照转移到所有衍生 Remix
- **G-3** —— [`02_api_contract.md`](02_api_contract.md) §7.3 新增 5 条业务错误码:
- `EMBED_RATE_LIMITED`iframe 嵌入超频)
- `EMBED_FORBIDDEN`(嵌入域名未在白名单)
- `ACCOUNT_DELETION_IN_PROGRESS`(注销窗口期内禁止其它写操作)
- `ACCOUNT_EXPORT_PENDING`(导出任务排队中)
- `PARENT_SNAPSHOT_TRANSFER_FAILED`E-19 快照转移失败可重试)
- **G-4** —— [`03_ios_app_plan.md`](03_ios_app_plan.md) §8.4 新增 `PrivacyManifest``PrivacyInfo.xcprivacy`)完整结构,覆盖 NSPrivacyTracking / NSPrivacyTrackingDomains / NSPrivacyCollectedDataTypes / NSPrivacyAccessedAPITypes 四组字段。
- **G-6** —— [`04_web_app_plan.md`](04_web_app_plan.md) §1.1 路由表新增 `R-16 /me/embeds`:用户管理自己发出的 iframe 嵌入列表与撤销面板。
- **G-7** —— [`04_web_app_plan.md`](04_web_app_plan.md) §1.1 路由表新增 `R-17 /admin/reports`:运营 reviewer 审核举报队列面板,含状态机视图。
- **G-8** —— [`03_ios_app_plan.md`](03_ios_app_plan.md) §1.2 IA 跳转图新增 `PrivacyDetail` 节点,作为「我的 → 隐私」一处看完所有开关的入口(对应 [`09_privacy.md`](09_privacy.md) PR-3 用户可控原则)。
- **G-9** —— [`01_data_schema.md`](01_data_schema.md) §3.11 新增「软删 vs 硬删分层策略」整章,所有用户数据表追加 `deleted_at timestamptz` 字段;定义 T+0 / T+7 / T+30 三个数据生命周期临界点的 RLS 行为。
- **G-10** —— [`01_data_schema.md`](01_data_schema.md) §3.2 `rooms.location_label` 字段显式加上「城市级 5 km 网格字符串、不含原始 GPS」注释,与 [`09_privacy.md`](09_privacy.md) §3.3 PR-2 最小采集原则绑定。
### Changed
- **G-5** —— [`03_ios_app_plan.md`](03_ios_app_plan.md) §8.1 修订 `NSLocationWhenInUseUsageDescription` 等 5 条位置权限文案,明确「会被其他用户看到」与「仅城市级,不含具体地址」两层声明;对应 [`09_privacy.md`](09_privacy.md) §4 P-3 拍板。
- 文档头版本号统一从 v0.1 升至 v0.2,并在文档头部说明本次修订涉及哪几条 G 漏洞。
### Fixed
- 无。本次修订全部为「追加 / 改进」,未修复 v0.1 既有 bug(v0.1 时尚无线上代码,无 bug 可修)。
### Security
- 通过 G-2E-17 / E-18)将 GDPR Art.17 删除权 + Art.20 数据可携权落到具体 API;
- 通过 G-4 PrivacyManifest 满足 Apple App Privacy Report 强制要求;
- 通过 G-9 软删分层避免 T+0 立即硬删导致级联破坏 Remix 衍生作品的合规风险。
---
## [0.1.0] - 2026-05-19
> **里程碑**CrowdRoom 设计从无到有,**7 份初版文档**全部落地,关键决策**5 条**拍板。这是项目的 ground truth 起点。
### Added
- [`00_overview.md`](00_overview.md) —— 总览:产品定位、5 Tab IA、技术栈选型、与 PRISM 复用边界、MVP 8 周范围、5 条根级风险 RK-1~5。
- [`01_data_schema.md`](01_data_schema.md) —— 数据模型:Supabase 8 张表 DDLv0.2 升至 9 张是 G-1 拆表的结果,v0.1 时为 8 张含 `room_versions`)、RLS 策略、Storage 公开/私有双桶目录、`layer_manifest.json` JSON Schema、CrowdRoom 4 层 ↔ PRISM L1L4 映射。
- [`02_api_contract.md`](02_api_contract.md) —— API 契约:16 个端点 E-01~E-16、转码 Worker 序列图、Remix 覆盖层 `remix_overlay.json` Schema、6 档配额表、4xx/5xx 错误码总表(v0.1 共 35 条,v0.2 增至 40 条)。
- [`03_ios_app_plan.md`](03_ios_app_plan.md) —— iOS 设计:5 Tab + 22 页面 IA、3 个核心 Flow(扫描脱敏上传 / 浏览跳 Web Remix / 通知跳详情)、端侧脱敏管线(RoomPlan → ModelIO → Vision → CIGaussianBlur)、三段式上传、5 阶段 Realtime 进度 UI、A/B/C 质量分、iOS-X1~X5 五条契约。
- [`04_web_app_plan.md`](04_web_app_plan.md) —— Web 设计:15 条路由(v0.2 增至 17 条)、Next.js + R3F + Zustand + Supabase SSR 技术栈、类 ArcGIS 4 层图层面板 + Named Views、材质替换(`material.map` 即时替换)、家具替换(OBB 自动对齐 + 4 自由度微调)、Remix 浏览器实时合成、SSR / OG / viewState 编码契约、Web-Y1~Y6 六条契约。
- [`09_privacy.md`](09_privacy.md) —— 隐私设计:5 条隐私原则 PR-1~5、数据流红黄绿三色图、三类敏感数据全生命周期、6 条 iOS 契约 P-1~P-6、3 条 Web 契约 P-W-1/2/7、GDPR + PIPL 合规清单(MVP 8 项 + P2 6 项)、16 项默认值表、8 个第三方 SDK 风险点。
- [`10_governance.md`](10_governance.md) —— 治理与社区:4 条治理原则 GR-1~4、UGC 自动审核流程(NSFW + 敏感词 DFA)、举报状态机、4 条治理契约 P-W-3~P-W-6、5 条社区规则 CR-1~5、4 级处罚阶梯 + 严重违规白名单越级、CC BY-NC 4.0 默认协议 / CC0 公共资产库、3 级申诉流程、90 天退场承诺与 CC0 镜像永久可访问。
### Decided(关键决策 Top 5
> 从全部 7 份文档识别出**最具方向性**的 5 条决策;完整 10 条版本见 [`README.md`](README.md) §4。
1. **拆 8 张表,不 7 张** —— 独立 [`room_versions`](01_data_schema.md) 表承载转码异步状态、Remix 父锁定、重传不破坏旧链接([`01_data_schema.md`](01_data_schema.md) §1 D1)。
2. **材质是逻辑层,不是 SQL 行** —— 4 层中只有「墙/地/家具」入 `layers` 表,「材质」在 `layer_manifest.json` 内嵌 `slots[]`[`01_data_schema.md`](01_data_schema.md) §1 D4)。
3. **Remix = 引用 + 覆盖层** —— 不深拷贝几何,浏览器实时合成 overlay([`02_api_contract.md`](02_api_contract.md) §1 D-A4 + §4)。
4. **三段式上传** —— 客户端 → Edge Function 拿 presigned → 直传 Storage → 回调 Edge Function;避开 4 MB body 限制([`02_api_contract.md`](02_api_contract.md) §1 D-A2 + [`03_ios_app_plan.md`](03_ios_app_plan.md) §5)。
5. **端侧脱敏不可降级** —— 人脸 `CIGaussianBlur` 必须在 iPhone 离开 App 进程前完成;服务端永不二次检测人脸([`09_privacy.md`](09_privacy.md) §1 PR-1 + [`03_ios_app_plan.md`](03_ios_app_plan.md) §4 + iOS-X1 契约)。
### Notes
- 本版本所有文档**仅设计稿,无对应代码**。
- v0.1 与 v0.2 同日发布;v0.2 是 v0.1 的 review-and-fix 增量版本(10 条 G 漏洞回写)。
- 后续版本将与代码里程碑(M1 MVP → v0.5-mvp)合并发布。
---
## 维护规约
1. **每次文档变更必须更新本日志**。修改 [`09_privacy.md`](09_privacy.md) 或 [`10_governance.md`](10_governance.md) 须 DPO + 法务双签后再写入;修改 [`01_data_schema.md`](01_data_schema.md) 字段语义须后端 + iOS + Web 三方签字。
2. **`[Unreleased]` 区块**只写「**已识别但未发布**」的待办;进入版本时把对应条目移入新版本号下。
3. **不要在 CHANGELOG 写未来计划**——未来在 [`ROADMAP.md`](ROADMAP.md)。本文件只记发生过的事。
4. **版本号变更规则**
- 任何「新增字段 / 新增端点 / 新增错误码 / 新增文档」→ MINOR 升一位(0.x → 0.(x+1)
- 任何「字段语义变更 / 端点删除 / 错误码改语义」→ MAJOR 升一位(破坏性变更,需迁移笔记)
- 文档润色 / typo 修复 → PATCH 升一位
5. **每个版本必须给出明确日期**YYYY-MM-DD),不允许「TBD」。
---
**文档版本**v0.2 · 2026-05-19
**维护者**CrowdRoom 设计组
**关键收获**:从 v0.1 初版 7 文档到 v0.2 的 10 条 G 漏洞回写,CrowdRoom 设计阶段共拍板 5 条根级决策 + 13 条隐私契约 + 4 条治理契约 + 6 条 iOS 契约 + 6 条 Web 契约 = **共 34 条对外契约**,构成 M1 MVP 8 周实施的全部输入。
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
# CrowdRoom · 项目入口(README
> **一句话定位****RoomPlan 版 Sketchfab + Pinterest** —— 一个让普通 iPhone 用户「扫一扫家、传上来、被别人 Remix」的众包房间扫描共享平台。
> **电梯陈述(80 字)**CrowdRoom 用 iOS RoomPlan 把每个房间变成一份「墙/地/家具/材质」四层数据,公开数据走 CDN,私有数据走 Supabase RLS,浏览器里直接在别人的房间上换材质、换家具、发表 Remix——零下载、零安装、零专业门槛。
> **当前版本**v0.22026-05-19)· 设计阶段完成,等待进入 M1 实施。
---
## 1. 它解决什么 / 不解决什么
| 维度 | Matterport | Sketchfab | Polycam | **CrowdRoom** |
|------|-----------|-----------|---------|---------------|
| **目标用户** | 房产中介、企业 | 3D 建模师、艺术家 | 测绘爱好者、个人用户 | **普通 iPhone 用户 + 装修灵感党** |
| **采集硬件** | 专业 Matterport Pro 相机(数千美元) | 用户自带任意 3D 模型文件 | iPhone LiDAR(与本平台相同) | **iPhone 12 Pro+ RoomPlan**(消费级即可) |
| **核心交互** | 全景浏览 + 标注 | 上传 / 下载 3D 模型 | 扫描 + 导出 OBJ/USDZ | **图层切换 + 浏览器换材质换家具 + Remix** |
| **数据分层** | 平面图 + 全景 | 单一 mesh / glTF 节点树 | 单一 mesh | **4 个固定逻辑层**(墙 / 地 / 家具 / 材质) |
| **Remix 模型** | ❌ 无 | 衍生作品(下载再上传) | ❌ 无 | **✅ Fork 模型**(引用 + 覆盖层,零拷贝几何) |
| **社区/隐私** | 商用账号体系 | 公开为主 | 个人为主 | **默认私有 + 端侧人脸脱敏 + CC BY-NC 默认协议** |
**CrowdRoom 不解决的事**[`00_overview.md`](00_overview.md) §7.2 已划入 MVP 外):
- ❌ **Android / Web 端采集**——RoomPlan 仅 iOS,且不引入 ARCore 替代
- ❌ **实时多人协同编辑**——Remix 走 fork 模型,不上 OT/CRDT
- ❌ **专业空间查询**PostGIS、空间 SQL、跨房间索引)——P2 再考虑
- ❌ **机器人接入与 PRISM Pipeline B/C/D**——CrowdRoom 是 PRISM 的**消费级前台**,不复用其重定位 / 在线感知 / 巩固管线
- ❌ **付费墙 / 订阅 / 链接电商**——MVP 走纯免费 + UGC 路线
---
## 2. 文档导航表
CrowdRoom 的 9 份设计文档(含本 README 与 [`ROADMAP.md`](ROADMAP.md))按编号对齐到「**总览 → 数据 → API → 客户端 → 隐私治理 → 路线图**」六层叙事。
| # | 标题 | 路径 | 一句话摘要 | 建议读者角色 |
|---|------|------|------------|-------------|
| 00 | 总览 | [`00_overview.md`](00_overview.md) | 产品定位、架构图、技术栈、与 PRISM 的复用边界、MVP 范围、5 条根级风险 | **所有人**起点 |
| 01 | 数据模型与 Storage | [`01_data_schema.md`](01_data_schema.md) | Supabase 9 张表 DDL + RLS + Storage 目录 + `layer_manifest.json` JSON Schema | 后端 / SRE / Web 工程师 |
| 02 | API 契约与转码管线 | [`02_api_contract.md`](02_api_contract.md) | 19 个端点(含 v0.2 新增 E-17/18/19+ 40 条错误码 + 转码序列图 + Remix 覆盖层 | 后端 / iOS / Web 工程师 |
| 03 | iOS App 设计 | [`03_ios_app_plan.md`](03_ios_app_plan.md) | 5 Tab 22 页面 IA + 端侧脱敏管线 + 三段式上传 + PrivacyManifest | iOS 工程师 |
| 04 | Web 端设计 | [`04_web_app_plan.md`](04_web_app_plan.md) | Next.js + R3F + 类 ArcGIS 图层面板 + 材质/家具替换 + Remix + OG 卡片 | Web 工程师 |
| 09 | 隐私设计 | [`09_privacy.md`](09_privacy.md) | 5 原则 PR-1~5 + 13 条契约(P-1~6 + P-W-1/2/7+ GDPR/PIPL 合规清单 | 隐私 / 法务 / PM |
| 10 | 治理与社区规则 | [`10_governance.md`](10_governance.md) | 4 治理原则 GR-1~4 + UGC 审核流程 + 4 级处罚阶梯 + CC BY-NC 默认协议 + 退场承诺 | 法务 / 运营 / PM |
| | 路线图 | [`ROADMAP.md`](ROADMAP.md) | 4 个里程碑(M1M4DoR/DoD + 12 条风险登记册 + 8 条 P2 候选 | PM / 创始人 / Tech Lead |
| | 变更日志 | [`CHANGELOG.md`](CHANGELOG.md) | Keep a Changelog 风格,记录 v0.1(初版 7 文档)→ v0.210 个 G 漏洞修订)→ Unreleased | 所有 reviewer |
### 2.1 按角色推荐阅读路径
| 角色 | 推荐顺序(3–5 步) |
|------|-------------------|
| **PM / 创始人** | [`00`](00_overview.md) → [`09`](09_privacy.md) → [`10`](10_governance.md) → [`ROADMAP`](ROADMAP.md) |
| **iOS 工程师** | [`00`](00_overview.md) → [`03`](03_ios_app_plan.md) → [`02`](02_api_contract.md) → [`09`](09_privacy.md) |
| **Web 工程师** | [`00`](00_overview.md) → [`04`](04_web_app_plan.md) → [`02`](02_api_contract.md) → [`01`](01_data_schema.md) |
| **后端 / SRE** | [`01`](01_data_schema.md) → [`02`](02_api_contract.md) → [`09`](09_privacy.md) → [`ROADMAP`](ROADMAP.md) §4 风险登记册 |
| **隐私 / 法务** | [`09`](09_privacy.md) → [`10`](10_governance.md) → [`00`](00_overview.md) §6 PRISM 边界 → [`CHANGELOG`](CHANGELOG.md) |
---
## 3. 整体架构一图(精简版)
```mermaid
graph TD
subgraph CLIENT[客户端层]
IOS[iOS App<br/>RoomPlan 采集 + 端侧脱敏 + 三段式上传]
WEB[Web 前端<br/>R3F + 4 层图层 + 材质家具替换 + Remix]
end
subgraph BAAS[Supabase BaaS]
AUTH[Auth<br/>Apple / Google / Email]
DB[Postgres 9 表<br/>+ RLS + tsvector 搜索]
STG[Storage<br/>公开 + 私有 双桶]
EDGE[Edge Functions<br/>19 端点]
RT[Realtime<br/>转码进度推送]
end
subgraph WORKER[渲染与转码层]
TRANS[Transcode Worker<br/>USDZ → glb + Draco/Meshopt]
ASSET[Asset Library<br/>CC0 公共素材]
end
CDN[CDN<br/>Cloudflare R2 + Bunny]
IOS -- 三段式上传 --> EDGE
IOS -- 直传 --> STG
EDGE -- 入队 --> TRANS
TRANS -- 写回 .glb + manifest --> STG
TRANS -- 状态更新 --> DB
DB -- Realtime --> IOS
STG -- 公开资源 --> CDN
WEB -- 列表/详情 --> DB
WEB -- 拉 .glb --> CDN
WEB -- 公共资产 --> ASSET
ASSET --> CDN
```
完整版本(含每条边的编号、时序、错误码)见 [`00_overview.md`](00_overview.md) §4。
---
## 4. 关键决策亮点 Top 10
从 7 份文档的「关键决策」中提炼最具方向性的 10 条:
1. **9 张表,不 7 张**——拆出独立 [`room_versions`](01_data_schema.md) 表承载转码异步状态、Remix 父锁定、重传不破坏旧链接([`01_data_schema.md`](01_data_schema.md) §1 D1)。
2. **材质是逻辑层,不是 SQL 行**——4 层中只有「墙/地/家具」入 `layers` 表,「材质」在 [`layer_manifest.json`](01_data_schema.md) 内嵌 `slots[]`,一次拉取即可完整渲染(D4)。
3. **Remix = 引用 + 覆盖层,不深拷贝几何**——同一房间衍生 N 个 Remix 不复制 110 MB `.glb`,浏览器实时合成([`02_api_contract.md`](02_api_contract.md) §4 D-A4)。
4. **三段式上传,不走 Edge Function 中转**——客户端 → Edge 拿 presigned → 直传 Storage → Edge 回调,避开 4 MB body 限制与冷启动计费爆炸(D-A2)。
5. **端侧脱敏不可降级**——人脸 `CIGaussianBlur` 必须在 iPhone 离开 App 进程前完成,服务端永不二次检测人脸([`09_privacy.md`](09_privacy.md) §1 PR-1 + iOS-X1)。
6. **位置永远城市级 5 km 网格**——不收原始 lat/lon、不存 GPS 精确坐标,地理标签只是 `location_label` 字符串(PR-2 + [`01_data_schema.md`](01_data_schema.md) §3.2 G-10)。
7. **默认私有,发布显式**——`rooms.visibility` 默认 `private`,用户必须主动勾选「公开」才进入发现流,零误公开(PR-4)。
8. **父硬删 → 快照转移到 Remix,不连坐删除**——CrowdRoom 站队 GitHub fork 模型,不站队小红书「删笔记 = 删评论」([`10_governance.md`](10_governance.md) §4 P-W-3 + GR-4)。
9. **MVP 全 CC0 公共资产库**——平台默认协议 CC BY-NC 4.0,但公共素材库严格只接 CC0;P1 起开放 CC-BY 走审核流水([`10_governance.md`](10_governance.md) §4 P-W-4 + §8)。
10. **退场承诺:公开数据 CC0 镜像永久可访问**——若产品下线,所有 `visibility=public` 房间作为 CC0 镜像移交非盈利机构,对照 Reddit/Flickr「关张归零」形成差异化合规底色([`10_governance.md`](10_governance.md) §10)。
---
## 5. 从这里开始(5 分钟入门)
> 目标:让一个新人在 5 分钟内对 CrowdRoom **是什么**与**该读什么**形成准确直觉。
1. **读 [`00_overview.md`](00_overview.md) §1–§2**(约 2 分钟)—— 拿到产品定位 + 目标用户画像。
2. **看本 README §3 架构图 + §4 Top 10 决策**(约 2 分钟)—— 一图 + 十条线索建立技术骨架。
3. **跳到自己角色对应的文档**(约 1 分钟入门,深入按需)—— 参考上方 §2.1 角色阅读路径表。
读完这 3 步后,你应能回答:
- ✅ CrowdRoom 和 Matterport / Sketchfab / Polycam 的本质区别是什么
- ✅ 数据从 iPhone 出来后经过哪几跳到达浏览器
- ✅ 4 个图层是哪 4 个、为什么材质是逻辑层
- ✅ 我作为 \<某角色\> 接手要先看哪 3 篇
---
## 6. 联系与贡献
- **项目方邮箱**(占位):`team@crowdroom.example`
- **隐私 / DPO**(占位):`privacy@crowdroom.example`[`09_privacy.md`](09_privacy.md) §9 SDK 风险清单中引用)
- **滥用举报**(占位):`abuse@crowdroom.example`[`10_governance.md`](10_governance.md) §2.2 举报流程入口)
- **下线归档接收方**(待定):Internet Archive / 某非盈利艺术机构([`10_governance.md`](10_governance.md) §10
### 6.1 文档变更 PR 流程(占位)
1. 任何对设计文档的修改必须以 PR 形式提交,且在 [`CHANGELOG.md`](CHANGELOG.md) 的 `[Unreleased]` 区块追加一行(Added / Changed / Fixed / Removed / Security 之一)。
2. 涉及 [`09_privacy.md`](09_privacy.md) 或 [`10_governance.md`](10_governance.md) 的修改,须 DPO + 法务双签。
3. 涉及 [`01_data_schema.md`](01_data_schema.md) 的字段语义变更,须 后端 + iOS + Web 三方签字(避免单方面破坏契约)。
4. 涉及 [`02_api_contract.md`](02_api_contract.md) 的错误码新增 / 修改,须保证向后兼容(不复用旧编号、不改旧语义)。
---
**文档版本**v0.2 · 2026-05-19
**维护者**CrowdRoom 设计组
**下一步阅读**[`ROADMAP.md`](ROADMAP.md) —— 看 M1 MVP 8 周怎么落地。
+258
View File
@@ -0,0 +1,258 @@
# CrowdRoom · 路线图(ROADMAP
> **文档目的**:把 7 份设计文档([`00`](00_overview.md)[`10`](10_governance.md))落地为 4 个可被 sprint planning 直接使用的里程碑,配套合并后的风险登记册、跨任务依赖图、v0.3 已知缺口、P2+ 候选清单。
>
> **基准时间**2026-05-19v0.2 设计完成日),M1 起点为 **W1 = 2026-W22**
>
> **本文档不重复**:每个里程碑的功能细节仍以 [`00_overview.md`](00_overview.md) §7、[`03_ios_app_plan.md`](03_ios_app_plan.md) §9、[`04_web_app_plan.md`](04_web_app_plan.md) §11 为准。
---
## 1. 里程碑总览(甘特图)
```mermaid
gantt
title CrowdRoom 4 个里程碑(M1 MVP → M2 v0.5 → M3 v1.0 → M4 P2
dateFormat YYYY-MM-DD
axisFormat W%V
section M1 MVP
iOS 采集 + 端侧脱敏 :m1a, 2026-05-25, 4w
Web R3F + 4 层切换 :m1b, 2026-05-25, 4w
Supabase 9 表 + 转码 Worker :m1c, 2026-05-25, 3w
材质替换 + 基础治理 :m1d, after m1a, 4w
M1 验收(DoD :milestone, m1m, after m1d, 0d
section M2 v0.5
家具替换 + OBB 自动对齐 :m2a, after m1m, 3w
Remix 发布 + 评论点赞 :m2b, after m1m, 4w
公共资产库 CC0 30+ 件 :m2c, after m1m, 2w
M2 验收(DoD :milestone, m2m, after m2b, 0d
section M3 v1.0
搜索 / 标签 / 全文 :m3a, after m2m, 2w
Named Views + iframe 嵌入 :m3b, after m2m, 3w
信任分系统初版 :m3c, after m2m, 4w
M3 验收(DoD :milestone, m3m, after m3c, 0d
section M4 P2
协同编辑(调研) :m4a, after m3m, 8w
AR 即时预览(调研) :m4b, after m3m, 8w
PRISM 反哺通道 :m4c, after m3m, 12w
```
> 甘特图中所有「after」依赖均与 §3 依赖图一致。M4 起点为 W25,但**无固定时间窗**——其内子任务的启动由 §6 P2 候选清单的「触发条件」决定。
### 1.1 里程碑速览表
| 里程碑 | 周次 | 目标版本 | 一句话目标 | 主要交付物 |
|--------|------|----------|------------|------------|
| **M1 MVP** | W1W8 | v0.5-mvp | 跑通「扫描 → 上传 → 浏览 → 4 层切换 → 换材质」最小闭环 | iOS TestFlight + Web Vercel + Supabase 9 表 + Worker + 30+ CC0 素材 |
| **M2 v0.5** | W9W16 | v0.5 | 把 Remix 与社区互动跑顺 | 家具替换 OBB 对齐 + Remix 发布 + 评论点赞 + 公共资产库 v1 |
| **M3 v1.0** | W17W24 | v1.0 | 把发现性与变现可能性补齐 | 全文搜索 + Named Views + iframe 嵌入 + 信任分初版 |
| **M4 P2** | W25+ | P2+ | 按触发条件启动长期演进 | 协同编辑 / AR / PRISM 反哺 / Android / 室外多房间 …(§6 |
---
## 2. 每个里程碑的 DoR / DoD
> **DoR (Definition of Ready)** = 进入该里程碑前必须满足的入口标准
> **DoD (Definition of Done)** = 离开该里程碑前必须达到的出口标准
### 2.1 M1 MVPW1W8
**DoR5 项)**
- [ ] 7 份设计文档冻结在 v0.2,且 [`CHANGELOG.md`](CHANGELOG.md) `[Unreleased]` 区块的 4 条 C-NEW 已分诊(修复 / 推迟 / 拒绝 三选一)
- [ ] Apple Developer 账号到位,TestFlight 内测组建立
- [ ] Supabase 项目创建(免费档),Cloudflare R2 + Bunny CDN 账户就绪
- [ ] 公共素材库种子集采购完成(≥ 30 件 CC0 家具 `.glb` + ≥ 20 种 PBR 材质)
- [ ] 1 名兼职 reviewer + 1 个 DPO 邮箱占位完成([`10_governance.md`](10_governance.md) §3.1
**DoD10 项)**
- [ ] iOS App 通过 TestFlight 审核,可邀请 ≥ 100 用户
- [ ] iOS-X1~X5 五条契约全部落地(端侧脱敏 ≤ 15 s on iPhone 12 Pro / 三段式上传 / Realtime 转码进度 / 5 Tab IA / PrivacyManifest
- [ ] Web 端 `/` `/r/[room_id]` `/login` `/upload` 4 个核心路由可用,桌面 ≥ 60 fps、移动 ≥ 30 fps[`04_web_app_plan.md`](04_web_app_plan.md) §3.3
- [ ] 4 层固定 IDwalls/floor/furniture/materials)可独立 toggle,且 viewState 可分享
- [ ] [`02_api_contract.md`](02_api_contract.md) 19 个端点中 **MVP 必须的 13 个**E-01~E-13, E-15)线上可用,40 错误码全部有用户文案
- [ ] 转码 Worker p95 ≤ 60 s(含 USDZ → glb + Draco + manifest 生成)
- [ ] 端到端测试:注册 → 扫描 → 上传 → 等待转码 → 公开发布 → 在 Web 浏览 → 切图层 → 换 1 个材质 7 步通跑
- [ ] [`09_privacy.md`](09_privacy.md) §6.1 的 8 项 GDPR MVP 必做项全部上线(含 PrivacyManifest、Cookie 通知、隐私政策页)
- [ ] 治理:举报通道 E-14 可用,处罚阶梯 L1–L3 工具就绪(L4 永封需 owner 终审)
- [ ] 内部 dogfood ≥ 100 个真实房间上传无 P0 事故
### 2.2 M2 v0.5W9W16
**DoR4 项)**
- [ ] M1 DoD 全部满足且线上稳定运行 ≥ 2 周
- [ ] 用户调研报告:M1 内测的 NPS ≥ 20、转化漏斗采集率 ≥ 30%
- [ ] OBB 自动对齐算法可行性 spike 通过([`04_web_app_plan.md`](04_web_app_plan.md) §6.3,超 1.5× 告警阈值已验证)
- [ ] Remix 覆盖层 `remix_overlay.json` Schema 在 100 个手工样本上无 round-trip 误差
**DoD8 项)**
- [ ] 家具替换可用:替换面板 + OBB 自动对齐 + 4 自由度微调 + 隐藏原家具开关
- [ ] Remix 端到端可用:从父房间 → Remix 编辑 → 自动保存 → 发布 → 在原房间页显示衍生作品树
- [ ] 评论 + 点赞两个互动可用,含速率限制与软删([`01_data_schema.md`](01_data_schema.md) §3.7, §3.8
- [ ] 公共资产库 v1:≥ 100 件家具 + ≥ 60 种材质,全部 CC0,资产页 `/assets` 可独立浏览
- [ ] [`02_api_contract.md`](02_api_contract.md) 剩余 6 个端点(E-14 举报、E-16 删除、E-17/18/19 v0.2 新增)全部上线
- [ ] [`10_governance.md`](10_governance.md) §4 P-W-3 父硬删快照转移流程在生产链路实测通过
- [ ] DAU ≥ 200,房间累计 ≥ 1 000
- [ ] 0 起合规事故,0 起公开数据外泄事故
### 2.3 M3 v1.0W17W24
**DoR4 项)**
- [ ] M2 DoD 全部满足
- [ ] 信任分计算公式经隐私 / 法务双签(不收集敏感画像)
- [ ] 嵌入沙盒安全审计通过(CSP / Referer / iframe sandbox 三件套,[`10_governance.md`](10_governance.md) §4 P-W-6
- [ ] tsvector 全文搜索性能在 10 万行级表上 p95 ≤ 200 ms[`01_data_schema.md`](01_data_schema.md) §1 D5
**DoD8 项)**
- [ ] 全文搜索可用:`/search?q=` + 标签筛选 + 排序(热度 / 新发布 / Remix 数)
- [ ] Named Views 可用:4 层组合可命名保存、可被分享、可被嵌入
- [ ] iframe 嵌入 `<iframe src="/embed/r/...">` 上线,含 [`/me/embeds`](04_web_app_plan.md) 管理面板与 [`/admin/reports`](04_web_app_plan.md) 审核面板(v0.2 G-6/G-7
- [ ] 信任分系统 v1:初始分 50 / 上限 100 / 因子 6 项(上传量、被 Remix 数、被举报数、申诉成功率、账号龄、ATT 配合度),可在 UserCenter 显示
- [ ] OG 卡片动态生成上线(`/api/og/r/[room_id]`
- [ ] DAU ≥ 1 000,房间累计 ≥ 10 000Remix 累计 ≥ 1 000
- [ ] 月度合规自检通过(GDPR + PIPL)
- [ ] App Store / Play Store 评分 ≥ 4.3 / 5
### 2.4 M4 P2W25+
**DoR(按子项分别评估,无统一入口)**:见 §6 P2 候选清单的「触发条件」列。
**DoD(仅给出 M4 整体退出标准)**:
- [ ] CrowdRoom 至少有一个 P2 特性进入 v2.0 主线
- [ ] PRISM ↔ CrowdRoom 数据交互通道至少完成单向(CrowdRoom → PRISM 先验地图源)的 PoC
---
## 3. 跨里程碑工作流依赖图
```mermaid
graph LR
subgraph M1[M1 MVP W1-W8]
T11[iOS 采集 + 端侧脱敏]
T12[Supabase 9 表 + RLS]
T13[Transcode Worker]
T14[Web 4 层切换]
T15[材质替换]
T16[基础治理 举报 + 处罚 L1-L3]
end
subgraph M2[M2 v0.5 W9-W16]
T21[家具替换 + OBB 对齐]
T22[Remix 发布 + overlay]
T23[评论 + 点赞]
T24[公共资产库 v1]
T25[E-17/18/19 + 父快照转移]
end
subgraph M3[M3 v1.0 W17-W24]
T31[全文搜索 + 标签]
T32[Named Views]
T33[iframe 嵌入]
T34[信任分 v1]
T35[OG 卡片]
end
subgraph M4[M4 P2 W25+]
T41[协同编辑]
T42[AR 即时预览]
T43[PRISM 反哺]
T44[Android]
T45[室外/多房间]
end
T12 --> T11
T12 --> T13
T13 --> T14
T14 --> T15
T11 --> T16
T15 --> T21
T14 --> T22
T12 --> T23
T15 --> T24
T16 --> T25
T22 --> T31
T15 --> T32
T22 --> T33
T16 --> T34
T22 --> T35
T34 --> T41
T11 --> T42
T22 --> T43
T11 --> T44
T11 --> T45
```
---
## 4. 风险登记册(Risk Register
> 来源:合并 [`00_overview.md`](00_overview.md) §8 RK-1~5、[`03_ios_app_plan.md`](03_ios_app_plan.md) §10 R-iOS-1~5、[`04_web_app_plan.md`](04_web_app_plan.md) §12 R-Web-1~6 共 16 条,去重 / 升级后保留 **13 条**
| ID | 描述 | 影响 | 概率 | 缓解措施 | 触发条件(升级到 P0) | 负责人 |
|----|------|------|------|----------|------------------------|--------|
| **RK-1** | RoomPlan 仅 iOS 且需 LiDARiPhone 12 Pro+),市场范围被硬绑 | 高(用户基数受限至 iPhone Pro 系列) | 已发生 | 接受为前提,不做 ARCore 替代;P2 视 Android DAU 增速决定是否引入 | iOS 月活 < 1 000 持续 3 个月 | PM |
| **RK-2** | `.usdz` 在 Web 端无原生 loader,转码失败率不可控 | 高(失败即用户作品丢失) | 中 | 服务端强转 `.glb`Worker 失败重试 3 次([`02_api_contract.md`](02_api_contract.md) §3.3);保留原始 90 天可申诉重转 | 转码失败率 > 5% 持续 1 周 | 后端 Lead |
| **RK-3** | UGC 违法 / 未授权他人住宅扫描 / NSFW 漏过 | 高(法律风险) | 中 | 上传声明 + 举报下架 + 人工 review + 严重违规白名单越级封禁([`10_governance.md`](10_governance.md) §2、§6) | 单日新增举报 > 50 持续 3 天 | 法务 + 运营 |
| **RK-4** | 端侧脱敏在低端机(iPhone 12 Pro)耗时超 8 s 预算 2 倍 | 中(用户流失) | 高(已实测 16 s) | UX 提示「正在处理,预计 15 秒」;不破例端侧不可降级原则(PR-1 + iOS-X1 | 12 Pro 用户上传放弃率 > 30% | iOS Lead |
| **RK-5** | 存储与带宽成本爆炸(千用户日活即可烧光免费额度) | 高 | 中 | 单用户配额 5 GB / 月、单文件 ≤ 50 MB;R2 零出口费托底;冷热分层 P1 引入 | 月度成本超预算 200% | SRE |
| **RK-6** | `.usdz` 沙箱内重打包不稳定(`ModelIO` 写回支持有限) | 中 | 中 | 备选方案:脱敏后贴图作为平行文件 PUT,Worker 合并;本决策待 v0.3 确认 | 重打包失败率 > 10% | iOS Lead |
| **RK-7** | Universal Link 在国内微信 / QQ 内不触发 | 中 | 已发生 | 引导用户「右上角 → 在 Safari 中打开」;微信小程序版 P2 再说 | 微信导流转化率 < 5% | iOS Lead |
| **RK-8** | Realtime channel 长后台丢事件 | 中 | 中 | App 前台化时主动补查所有进行中版本([`03_ios_app_plan.md`](03_ios_app_plan.md) §6.3) | 用户投诉「转码完成无通知」单周 > 20 | iOS Lead |
| **RK-9** | 移动 Safari 上 Three.js 大场景 < 30 fps | 中 | 中 | 移动预算 ≤ 2 MB95 分位掉帧时 fallback 到 `<model-viewer>` 静态预览 | 移动端跳出率 > 60% | Web Lead |
| **RK-10** | 大场景首屏 TTI > 6 s>20 MB `.glb`) | 中 | 中 | 上传配额限 single `.glb` ≤ 15 MB;P1 引入 LOD;转码强制拒收 > 50 MB 待 v0.3 | 95 分位 LCP > 5 s | Web Lead |
| **RK-11** | Remix 父房间删除冲突(离线 1 周 + 父硬删) | 中 | 低 | [`10_governance.md`](10_governance.md) §4 P-W-3 拍板「快照转移」;E-19 端点已设计 | 父硬删后 Remixer 投诉 > 5 起 | 法务 + 后端 |
| **RK-12** | 公共资产库版权审查负担(CC0 → CC-BY 演进) | 低 | 中 | MVP 全 CC0 硬过滤;CC-BY 准入流水 M3 之后再开([`10_governance.md`](10_governance.md) §4 P-W-4) | 创作者「上传素材」诉求 > 100 / 月 | 运营 |
| **RK-13** | OG 动态截图需要 WebGLVercel Edge 不支持 | 低 | 已发生 | 容器化 screenshot workerFly.io);或 MVP 跳过动态 OG,用静态首帧 | OG 卡片缺失率 > 50% | Web Lead |
> 1416 号风险(RoomPlan 精度公差、AVCaptureDevice ISO 检测、SDK 接入新增)在 v0.2 合并时与上表条目语义重叠,已并入 RK-3 / RK-4。
---
## 5. v0.3 待办(已知缺口)
> **说明**:以下 4 条 **C-NEW-1 ~ C-NEW-4** 是子任务 7(本路线图)在汇总 v0.2 全部文档时识别出的「跨文档新设计冲突」。它们不是 v0.2 既有 G 漏洞的延伸,而是 09/10 文档落地后**新暴露**的契约缺口;建议在 **v0.3 review 窗口**M1 W4 之前)回写到对应文档。
>
> ⚠️ 若您手上有子任务 6 原作者 attempt_completion 中给出的 C-NEW-1~4 原文,请覆盖以下条目;本表为基于跨文档静态分析得到的合理推断。
| ID | 新冲突描述 | 回写位置(建议) | 建议时间窗 | 现状判断 |
|----|-----------|------------------|------------|----------|
| **C-NEW-1** | **viewState 编码确定性 vs 强 hash 反画像 的矛盾**[`04_web_app_plan.md`](04_web_app_plan.md) §9.3 把 viewState 设为「gzip+base64url 确定性编码」(同输入 → 同输出,便于缓存 OG),但 [`09_privacy.md`](09_privacy.md) §5 P-W-7 要求 viewState 在 Realtime / 日志中作为画像数据进行强 hash —— 「确定性」与「不可逆」直接冲突 | [`02_api_contract.md`](02_api_contract.md) §5 viewState[`09_privacy.md`](09_privacy.md) §5 P-W-7;可能新增「双轨编码:URL 用确定性、日志用 HMAC」 | M1 W2 前 | **必修**(决定 OG 缓存策略) |
| **C-NEW-2** | **父硬删快照转移(E-19)与作者账号注销(E-17)的并发未定义**[`10_governance.md`](10_governance.md) §4 P-W-3 要求父硬删时几何快照转移到 Remix;[`02_api_contract.md`](02_api_contract.md) E-17 把账号注销定义为「T+0 软删 / T+7 不可撤 / T+30 硬删」。若注销中途用户有 N 个被他人 Remix 的公开房间,转移逻辑触发时点 / 失败回滚 / 通知顺序均未定义 | [`02_api_contract.md`](02_api_contract.md) §3 转码管线后补充「注销级联管线」;[`01_data_schema.md`](01_data_schema.md) §3.11 软/硬删分层补充 E-19 与 E-17 的交互矩阵 | M1 W3 前 | **必修**(关系数据完整性) |
| **C-NEW-3** | **位置标签城市级 5 km 的数据流端到端未对齐**[`09_privacy.md`](09_privacy.md) §3.3 + §7 把位置定义为「永远城市级 5 km」,[`01_data_schema.md`](01_data_schema.md) §3.2 G-10 已加 `location_label` 显式标注,但 [`03_ios_app_plan.md`](03_ios_app_plan.md) §8.1 的 `NSLocationWhenInUseUsageDescription` 文案没说「5 km 网格」,且 iOS 端是否在客户端做 5 km 量化、还是直接拒收原始 GPS、还是服务端量化未定义 | [`03_ios_app_plan.md`](03_ios_app_plan.md) §8.1 文案 + §1.2 UploadForm 字段说明;[`02_api_contract.md`](02_api_contract.md) E-02 `room-create` 请求体添加 `location_label` 校验规则 | M1 W2 前 | **必修**(合规底线) |
| **C-NEW-4** | **信任分系统(M3 计划)的初始分计算口径未在 v0.2 任何文档定义**[`10_governance.md`](10_governance.md) §3.2 提到「P1 引入信任分」但没有公式;M3 DoD 已写「初始分 50 / 上限 100 / 因子 6 项」,但这 6 项的权重、衰减、与 ATT / 举报 / 申诉的勾稽关系未拍板 | 新增 [`plans/CrowdRoom/11_trust_score.md`](11_trust_score.md)(M2 W14 启动撰写);或并入 [`10_governance.md`](10_governance.md) §3 团队规模章 | M2 W14 前(M3 DoR 倒推) | **可推迟**(不影响 M1 / M2 上线) |
---
## 6. 未来扩展(P2+)特性候选清单
> 列出 ≥ 8 条 MVP–v1.0 不做、但已有用户 / 数据信号的扩展。**每条标注「为何 MVP 不做」与「P2 引入的触发条件」**。条目按预计优先级排列。
| # | 特性 | 一句话价值 | 为何 MVP 不做 | P2 引入触发条件 |
|---|------|------------|---------------|------------------|
| **F-1** | **PRISM 反哺**:把高质量 CrowdRoom 房间作为 PRISM 的先验地图源 | 让 PRISM 机器人系统拿到「群众贡献的真实家庭空间」作为初始地图 | CrowdRoom 数据 SLA / 安全模型与 PRISM 完全不同([`00_overview.md`](00_overview.md) §6.3),不可直接打通 | **DAU > 1 000 且累计公开房间 > 10 000**M3 DoD 达成后) |
| **F-2** | **协同编辑**:多人同房间 RemixOT / CRDT | Pinterest 式分享之外,再加 Figma 式协作 | Remix 走 fork 模型已足够([`02_api_contract.md`](02_api_contract.md) §4 D-A4),引入 CRDT 后端复杂度跳 5 倍 | **v1.0 上线后用户调研显示 ≥ 20% 用户希望多人编辑** |
| **F-3** | **AR 即时预览**Vision Pro / iPhone AR Quicklook 在真实空间叠加 Remix 结果 | 让用户「站在自家客厅,看到换沙发后的样子」 | RoomPlan 已提供基础几何,但 Vision Pro 装机量仍低;Quicklook 在 iOS 16+ 受限 | **Vision Pro 装机量 > 5% iOS 用户**,或 Quicklook 公开 API 支持 .glb 覆盖层 |
| **F-4** | **Android 端采集**:用 ARCore Depth API 替代 RoomPlan | 解锁安卓用户群 | RoomPlan 仅 iOS[`00_overview.md`](00_overview.md) §6.3);ARCore 数据格式差异会导致**双标准** | **iOS DAU > 5 000 且月增 > 15% 持续 3 个月**,并完成 ARCore↔RoomPlan 字段映射 spike |
| **F-5** | **室外 / 多房间扫描** | 支持别墅 / 商铺 / 多间公寓 | RoomPlan 单次最佳 < 50 m²;超出范围误差激增([`plans/iphone/roomplan_accuracy_and_cad_export.md`](../iphone/roomplan_accuracy_and_cad_export.md) §1 | **用户上传含户外/多房间 .usdz 占比 > 10%**,且 RoomPlan 后续版本扩大支持范围 |
| **F-6** | **CC-BY 资产准入流水** | 让创作者上传自制家具 / 材质,扩充资产库 | MVP 全 CC0 硬过滤([`10_governance.md`](10_governance.md) §4 P-W-4),引入 CC-BY 需新增审核 SOP 与署名链 | **创作者「上传素材」诉求 > 100 / 月**,且法务确认 CC-BY 二次传播的 attribution 链可机审 |
| **F-7** | **付费 marketplace**:高价值素材 / 整套设计 | 让顶级 Remixer 与设计师变现 | MVP 走纯免费 UGC 路线([`00_overview.md`](00_overview.md) §7.2),收费引入合规与税务一整套 | **Top 1% 创作者收入意愿调研 > 50%**,且支付牌照(Stripe / 国内)就绪 |
| **F-8** | **空间 SQL / PostGIS 查询** | 「按城市 / 户型 / 面积 / 朝向」精确查找房间 | 消费级路线决策([`00_overview.md`](00_overview.md) §6.3);MVP 用 tsvector + tags 已够 | **用户主动诉求「按城市/按户型查询」单月 > 500**,或与第三方家装品牌签约要求 |
| **F-9** | **PRISM Pipeline B 重定位接入** | 让同一房间被多次扫描时自动对齐 | 用户每次扫描视为独立作品([`00_overview.md`](00_overview.md) §6.3 | **「扫描日记 / 同房间历史对比」功能立项**,且单房间复扫率 > 5% |
| **F-10** | **微信小程序版 Remix 入口** | 绕开 Universal Link 在国内浏览器跳转受限(RK-7) | 小程序 3D 渲染受限(无 R3F);MVP 走 PWA 浏览器即可 | **微信导流转化率持续 < 5% 且占总流量 > 30%** |
> **抽象规则**:所有 F-x 进入实际开发前必须先做一份独立 spike 文档(建议放 `plans/CrowdRoom/p2/F-x_<name>.md`),并在 [`CHANGELOG.md`](CHANGELOG.md) `[Unreleased]` 区块预留 `Planned` 类别(Keep a Changelog 自定义类型)。
---
## 7. 路线图维护规约
1. **每个里程碑结束时**DoD 全部通过 → 在 [`CHANGELOG.md`](CHANGELOG.md) 创建对应版本(v0.5-mvp / v0.5 / v1.0);DoD 未通过 → 在本路线图 §2 该里程碑标注延期原因。
2. **风险登记册(§4**:每个 sprint 复盘会重新评估「概率 / 触发条件」两列;任一条目触发则升级为 P0 工单。
3. **v0.3 待办(§5**:M1 W4 前必须分诊完毕(修复 / 推迟 / 拒绝 三选一);任何修复必须同步回写到对应文档与 [`CHANGELOG.md`](CHANGELOG.md)。
4. **P2 候选(§6**:每个里程碑结束时复查触发条件是否达成;达成则立项 spike。
5. **不要预测未来**:本文件可写「触发条件」与「DoR」,但不写「预计交付时间」之外的承诺。任何已发生的事进 [`CHANGELOG.md`](CHANGELOG.md)。
---
**文档版本**v0.2 · 2026-05-19
**维护者**CrowdRoom 设计组
**关键收获**4 里程碑 × 30 条 DoR/DoD × 13 条风险 × 4 条 v0.3 待办 × 10 条 P2 候选 = 一份 PM 可直接拿去开 sprint planning 的完整路线图;M1 8 周即可拿到「扫描 → 上传 → 浏览 → 4 层切换 → 换材质」最小闭环。