commit bec8a9a4a3437349b08e4baee4c42c05673e1234 Author: gaojie Date: Wed May 20 21:43:57 2026 +0800 chore: initial commit — import worldmodel workspace (plans/, research/) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d436a61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# macOS +.DS_Store +**/.DS_Store + +# Python +*.pyc +__pycache__/ +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Node +node_modules/ + +# Editors / IDE +.idea/ +.vscode/ + +# Logs +*.log + +# Large research PDFs +research/lyra2_paper.pdf + +# PRISM build artifacts +plans/PRISM/.build/ +plans/PRISM/PRISM_Book.pdf +plans/PRISM/PRISM_Cover.pdf +plans/PRISM/PRISM_Whole.pdf diff --git a/plans/CrowdRoom/00_overview.md b/plans/CrowdRoom/00_overview.md new file mode 100644 index 0000000..3565050 --- /dev/null +++ b/plans/CrowdRoom/00_overview.md @@ -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
(RoomPlan SDK 采集 + 上传客户端)
Swift / SwiftUI"] + WEB["Web 前端
(浏览/分层/Remix)
React + R3F + Three.js"] + end + + subgraph BAAS["Supabase BaaS 层"] + AUTH["Auth
邮箱/Apple/Google 登录
JWT 下发"] + DB["Postgres
rooms / layers / remixes /
comments / likes 表"] + STG["Storage
原始 .usdz + JSON
+ 转码后 .glb"] + EDGE["Edge Functions
上传回调 / 隐私脱敏触发 /
排行榜聚合"] + end + + subgraph WORKER["渲染与转码层"] + TRANS["Transcode Worker
(USDZ → glTF/.glb +
Draco/Meshopt 压缩)
容器化 Node/Python"] + ASSET["Asset Library
公共家具 .glb +
PBR 材质贴图"] + end + + subgraph CDN["分发层"] + EDGECDN["CDN
(Cloudflare R2 / Bunny)
直发 .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 端只消费、不采集 +- BaaS(Supabase)= 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 `` Asset Pack + 自建 PBR 库** | Sketchfab API | 起步用免费 CC0 素材,避开版权 | +| CI/CD | **GitHub Actions**(iOS 走 fastlane → TestFlight;Web 走 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 App(TestFlight) | 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` 5–50 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 之外。 diff --git a/plans/CrowdRoom/01_data_schema.md b/plans/CrowdRoom/01_data_schema.md new file mode 100644 index 0000000..ff945ce --- /dev/null +++ b/plans/CrowdRoom/01_data_schema.md @@ -0,0 +1,981 @@ +# CrowdRoom · 数据模型与 Storage 规范(v0.2) + +> **版本**:v0.2(2026-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) L1–L4 的映射。 +> +> **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-10:location_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-9:Remix 软删(作者注销时统一走软删流;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_at,30 天后硬删) + 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 两层删除模型 + +| 层 | 调用方 | 操作 | 数据状态 | +|----|--------|------|---------| +| **业务 API(PostgREST + 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-export(02_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 bucket,CDN 可缓存);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 对象走 CDN,URL 形如 `https://{project}.supabase.co/storage/v1/object/public/rooms/{room_id}/v{n}/canonical.glb` +- 私有 bucket 由 Edge Function 签发 presigned URL,TTL 默认 60 s(上传)/ 600 s(下载) +- 所有写入路径在 Worker 端走 `{room_id}/v{n}/.tmp/` 暂存目录,转码完成后 `rename` 到正式路径,避免半成品被读到 + +--- + +## 5. `layer_manifest.json` JSON Schema(Draft 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 L1–L4 映射 + +参考 [`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 走 PostgREST;Worker 用 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 降维投影"。 \ No newline at end of file diff --git a/plans/CrowdRoom/02_api_contract.md b/plans/CrowdRoom/02_api_contract.md new file mode 100644 index 0000000..8423e38 --- /dev/null +++ b/plans/CrowdRoom/02_api_contract.md @@ -0,0 +1,515 @@ +# CrowdRoom · API 契约与转码管线(v0.2) + +> **版本**:v0.2(2026-05-19) +> **v0.2 修订**:回写 G-2(追加 3 个 Edge Function 端点 E-17/E-18/E-19,对应账号注销三阶段、数据导出、父硬删时 Remix 快照转移)+ G-3(追加 5 条业务错误码 `EMBED_RATE_LIMITED` / `EMBED_FORBIDDEN` / `ACCOUNT_DELETION_IN_PROGRESS` / `ACCOUNT_EXPORT_PENDING` / `PARENT_SNAPSHOT_TRANSFER_FAILED`)。源决策见 [`09_privacy.md`](09_privacy.md) §4 P-4、§5 P-W-7 与 [`10_governance.md`](10_governance.md) §4 P-W-3 / P-W-6。 +> **编号说明**:任务原文建议新端点编号 E-16/17/18,但原 v0.1 §2.1 中 E-16 已占用为 `DELETE /rest/v1/rooms`;为不破坏既有引用,新端点顺延为 **E-17 / E-18 / E-19**,同时把原 E-16 标注为「v0.2 起被 E-17 软封装」。 + +> 本章承接 [`00_overview.md`](00_overview.md) §4 架构图与 [`01_data_schema.md`](01_data_schema.md) 的 9 张表,定义客户端 ↔ Supabase 的所有 API、Edge Function 业务逻辑、转码 Worker 的 sequence,以及 Remix 覆盖层、配额、错误码。 +> +> **API 路径 / 字段 / 错误码用英文**;解释用简体中文。 + +--- + +## 1. API 分层决策(先拍板) + +CrowdRoom 把 API 分到三条管道,避免"什么都往 Edge Function 塞"或"什么都让客户端直查 PostgREST"两个极端。 + +| # | 关键决策 | 拍板 | 理由 | +|---|---------|------|------| +| D-A1 | 列表/详情/搜索是否走 PostgREST | **走 PostgREST**(不写 Edge Function) | RLS 已经把可见性收得很死,PostgREST 自动生成的 OpenAPI 完全够 React Query 直接消费;Edge Function 反而要重复维护一份过滤逻辑 | +| D-A2 | 上传 .usdz 怎么走 | **客户端 → Edge Function 拿 presigned URL → 直传 Storage → 回调 Edge Function** | 30 MB+ 文件不该走 Edge Function 中转(4MB body 限制 + 冷启动 + 计费爆炸);presigned URL 把流量留在 Storage | +| D-A3 | 点赞防刷 | **走 Edge Function `like-toggle`**(不让客户端直 INSERT) | 客户端能拿到自己的 JWT,可以一秒发几百次;Edge Function 做 60 s 速率限制 + 幂等键,比在 Postgres 写 advisory lock 简单 | +| D-A4 | Remix 是深拷贝几何还是覆盖层 | **覆盖层(reference + overlay)**,只存 diff | 同一房间衍生 N 个 remix 不能每次复制 1–10 MB 的 .glb;Web 端在浏览器里实时合成覆盖层;几何来源永远是父版本的 `canonical.glb`(见 §4) | +| D-A5 | 转码 Worker 在哪 | **独立容器(Fly.io / Railway),由 Edge Function 通过 HTTP POST 入队** | Supabase Edge Functions 跑不动 USD CLI 与 `gltf-transform`(依赖 native binaries、内存 1GB+);分离后 Worker 可以独立扩缩容 | + +### 1.1 三条管道总览 + +```mermaid +graph LR + CLIENT[iOS / Web] + PGRST[Supabase PostgREST
auto-gen from tables] + EDGE[Edge Functions
Deno runtime] + STG[Supabase Storage
direct upload/download] + WORKER[Transcode Worker
Fly.io container] + DB[(Postgres)] + + CLIENT -- "GET rooms, comments, assets
SELECT 走 RLS" --> PGRST + PGRST --> DB + + CLIENT -- "POST upload-init / transcode-done
POST like-toggle / remix-create" --> EDGE + EDGE -- "service_role 写 status / counters" --> DB + EDGE -- "presigned URL 签发 / Realtime 通知" --> CLIENT + EDGE -- "HTTP POST job" --> WORKER + + CLIENT -- "PUT source.usdz
GET canonical.glb" --> STG + WORKER -- "GET source.* / PUT canonical.*" --> STG + WORKER -- "POST transcode-done" --> EDGE +``` + +--- + +## 2. 核心 API 端点表 + +> 路径前缀: +> - PostgREST:`https://{project}.supabase.co/rest/v1/` +> - Edge Functions:`https://{project}.supabase.co/functions/v1/` +> - Storage:`https://{project}.supabase.co/storage/v1/` +> +> **鉴权要求** 列:`anon` = 任何人;`user` = 必须携带用户 JWT;`owner` = 必须为资源 owner(由 RLS 强制);`service` = 仅 Worker 用 service_role key。 +> +> **速率限制** 默认走 Cloudflare WAF + Edge Function 内 KV 计数;列中只标"敏感端点"特殊值。 + +### 2.1 端点全表(共 16 条) + +| # | METHOD | 路径 | 通道 | 入参 | 出参 | 鉴权 | 速率 | 失败码 | +|---|--------|------|------|------|------|------|------|--------| +| E-01 | POST | `/functions/v1/upload-init` | Edge | `{ room_id?, version_no?, title, tags[], visibility, bytes_source }` | `{ room_id, version_id, version_no, presigned_usdz, presigned_json, expires_at }` | user | 10/min/user | `QUOTA_EXCEEDED`, `FILE_TOO_LARGE`, `INVALID_TAGS` | +| E-02 | PUT | `/storage/v1/object/private/rooms/{room_id}/v{n}/source.usdz` | Storage | binary(presigned) | `200 OK` | (presigned) | — | `STORAGE_FORBIDDEN` | +| E-03 | PUT | `/storage/v1/object/private/rooms/{room_id}/v{n}/source.roomplan.json` | Storage | binary(presigned) | `200 OK` | (presigned) | — | `STORAGE_FORBIDDEN` | +| E-04 | POST | `/functions/v1/upload-complete` | Edge | `{ version_id, redactions[] }` | `{ version_id, status: 'queued' }` | user(owner) | 10/min/user | `VERSION_NOT_FOUND`, `SOURCE_MISSING`, `REDACTION_INVALID` | +| E-05 | POST | `/functions/v1/transcode-done` | Edge | `{ version_id, status, manifest_path, glb_path, thumbnail_path, summary, error? }` | `{ ok: true }` | service | — | `BAD_SIGNATURE`, `LAYER_MANIFEST_INVALID`, `ROOM_TRANSCODE_FAILED` | +| E-06 | GET | `/rest/v1/rooms?visibility=eq.public&order=created_at.desc&limit=20` | PostgREST | query string | `Room[]` | anon | — | `RLS_DENIED` | +| E-07 | GET | `/rest/v1/rooms?id=eq.{room_id}&select=*,current_version:room_versions(*)` | PostgREST | path/query | `Room` | anon/owner | — | `ROOM_NOT_FOUND`, `RLS_DENIED` | +| E-08 | GET | `/rest/v1/rpc/search_rooms?q={text}&tag={tag}` | PostgREST | `q, tag, limit` | `Room[]` | anon | 60/min/IP | `SEARCH_QUERY_TOO_SHORT` | +| E-09 | GET | `/storage/v1/object/public/rooms/{room_id}/v{n}/layer_manifest.json` | Storage(CDN) | — | manifest JSON | anon | — | `MANIFEST_NOT_FOUND` | +| E-10 | POST | `/functions/v1/view-state` | Edge | `{ room_id, version_id, layer_toggles, camera, overlay_id? }` | `{ share_token, share_url }` | anon/user | 30/min | `VIEW_STATE_INVALID` | +| E-11 | POST | `/functions/v1/remix-create` | Edge | `{ parent_version_id, title, overlay }` | `{ remix_id, overlay_path }` | user | 20/min/user | `REMIX_PARENT_DELETED`, `REMIX_PARENT_NOT_READY`, `OVERLAY_INVALID`, `ASSET_NOT_FOUND` | +| E-12 | POST | `/functions/v1/like-toggle` | Edge | `{ room_id }` | `{ liked: bool, like_count: int }` | user | **5/sec/user**(防刷) | `RATE_LIMITED`, `ROOM_NOT_FOUND` | +| E-13 | POST | `/rest/v1/comments` | PostgREST | `{ room_id, body, reply_to? }` | `Comment` | user | 30/min/user | `COMMENT_TOO_LONG`, `RLS_DENIED` | +| E-14 | POST | `/functions/v1/report` | Edge | `{ target_type, target_id, reason, detail? }` | `{ report_id }` | user | 10/hour/user | `REPORT_DUPLICATE`, `INVALID_TARGET` | +| E-15 | GET | `/functions/v1/quota` | Edge | — | `Quota`(见 §6) | user | — | — | +| E-16 | DELETE | `/rest/v1/rooms?id=eq.{room_id}` | PostgREST | path | `204` | owner | — | `RLS_DENIED`, `ROOM_HAS_REMIXES` | +| E-17 | POST | `/functions/v1/room-delete-with-snapshot` | Edge | `{ room_id, mode: 'soft'\|'hard' }` | `{ room_id, mode, deleted_at, snapshots_transferred: int }` | user(owner) | 5/min/user | `RLS_DENIED`, `ROOM_NOT_FOUND`, `PARENT_SNAPSHOT_TRANSFER_FAILED`, `STORAGE_PUT_FAILED` | +| E-18 | POST | `/functions/v1/account-delete` | Edge | `{ confirm_password, stage?: 't0'\|'t7_revoke'\|'t30_force' }` | `{ user_id, stage, deleted_at, hard_delete_eta }` | user | 1/hour/user | `UNAUTHENTICATED`, `ACCOUNT_DELETION_IN_PROGRESS`, `PARENT_SNAPSHOT_TRANSFER_FAILED` | +| E-19 | GET | `/functions/v1/account-export` | Edge | `?include=rooms,remixes,comments,likes,redactions,profile`(默认全选) | `{ export_id, status: 'pending'\|'ready', download_url?, expires_at? }` | user | 2/day/user | `UNAUTHENTICATED`, `ACCOUNT_EXPORT_PENDING`, `QUOTA_EXCEEDED` | + +> 共 **19 个端点**(v0.2):11 个 Edge Function、6 个 PostgREST、2 个 Storage 直传/直读。新增 3 条均为 v0.2 / G-2 回写,源决策 [`09_privacy.md`](09_privacy.md) §4 P-4 与 [`10_governance.md`](10_governance.md) §4 P-W-3。 + +> 🔄 **v0.2 — 回写自 G-2**:E-16 `DELETE /rest/v1/rooms` 在 v0.2 起**仅供「无任何公开 Remix 子代」的房间使用**;存在公开 Remix 时必须改走 E-17,由 Edge Function 先做快照转移再 cascade 删,否则 RLS / DB 层会因 `room_versions → remixes` 的 `on delete restrict` 抛 `ROOM_HAS_REMIXES`。客户端发现 E-16 返回 `ROOM_HAS_REMIXES` 时应自动 fallback 到 E-17。 +> +> #### E-17 `/room-delete-with-snapshot` 逻辑骨架(v0.2 / G-2 新增) +> +> 1. 入参校验:`auth.uid() = rooms.owner_id`,否则 `RLS_DENIED` +> 2. 若 `mode='soft'`:`UPDATE rooms SET deleted_at=now(), visibility='private' WHERE id=$1`,立即从 Feed 与搜索消失(详见 [`01_data_schema.md`](01_data_schema.md) §3.11) +> 3. 若 `mode='hard'`: +> - service_role 事务内遍历所有 `remixes` 子代(`is_public=true AND deleted_at IS NULL`) +> - 对每个被引用的 `room_versions`,把 `canonical_glb_path` / `manifest_path` 复制到 `public/remix-fallbacks/{parent_room_id}/v{n}/` +> - 更新 `remixes.parent_snapshot_path` 字段([`01_data_schema.md`](01_data_schema.md) §3.6 v0.2 新增字段) +> - 复制完成后 `DELETE FROM public.rooms WHERE id=$1` → cascade 子表 + 删除原 Storage 路径 +> - 任一步失败 → 事务回滚 + Storage 复制产物异步 GC + 返回 `PARENT_SNAPSHOT_TRANSFER_FAILED` +> 4. 返回 `snapshots_transferred = N`(让 UI 提示「N 个公开 Remix 已自动保留」) +> +> #### E-18 `/account-delete` 逻辑骨架(v0.2 / G-2 新增) +> +> 三阶段流程对齐 [`09_privacy.md`](09_privacy.md) §4 P-4: +> +> | 阶段 | `stage` 参数 | 行为 | 用户可挽回 | +> |------|-------------|------|-----------| +> | T+0 | `'t0'`(默认;调用方提交 `confirm_password`) | 软删 `users / rooms / remixes / comments / likes`(设 `deleted_at=now()`),`rooms.visibility='private'`,JWT 即刻失效;返回 `hard_delete_eta = now() + 30d` | ✅ 7 天内通过 DPO 邮箱可走 `stage='t7_revoke'` 撤销 | +> | T+7 | — | 业务 API 拒绝任何撤销请求 → `ACCOUNT_DELETION_IN_PROGRESS` | ❌ | +> | T+30 | `'t30_force'`(仅 service_role 在 `pg_cron` 02:00 调用) | 对该 user 的每个 `rooms` 依次走 E-17 `hard` 流程 → 然后 `DELETE FROM users WHERE id=$1`(cascade 影子表 + auth.users) | ❌ | +> +> #### E-19 `/account-export` 逻辑骨架(v0.2 / G-2 新增) +> +> 1. 入参 `?include=...` 控制导出范围(默认全选);输出 ZIP 包含:`profile.json / rooms.json / remixes.json / comments.json / likes.json / redactions.json / source/` 目录(私有 bucket 中的 `.usdz / .roomplan.json`) +> 2. 异步执行:首次调用返回 `{ status: 'pending', export_id }`;客户端轮询同端点带 `?export_id=...` 获取 `{ status: 'ready', download_url, expires_at }` +> 3. `download_url` 是 Storage presigned URL,TTL = 24h,单次签发;过期重新调 E-19 +> 4. 并发限制:同 user 同时只允许 1 个 export 任务,重复调用返回 `ACCOUNT_EXPORT_PENDING` +> 5. 对应 [`09_privacy.md`](09_privacy.md) §6.1 C-4(GDPR Art. 20 数据可携权) + +### 2.2 PostgREST 列表请求示例(E-06) + +```http +GET /rest/v1/rooms?visibility=eq.public + &order=created_at.desc + &limit=20&offset=0 + &select=id,title,tags,cover_color,like_count, + owner:users(handle,display_name,avatar_url), + current_version:room_versions!current_version_id(thumbnail_path,status) +Authorization: Bearer {anon_key} +apikey: {anon_key} +``` + +返回经 RLS 过滤后的列表;客户端拿 `thumbnail_path` 拼 CDN URL 即可。 + +### 2.3 全文搜索 RPC(E-08) + +`search_rooms` 是 Postgres function,封装 tsvector + 标签过滤: + +```sql +create function public.search_rooms(q text, tag text default null, lim int default 20) +returns setof public.rooms +language sql stable +as $$ + select r.* from public.rooms r + where r.visibility = 'public' + and (q is null or r.search_tsv @@ plainto_tsquery('simple', q)) + and (tag is null or tag = any(r.tags)) + order by ts_rank(r.search_tsv, plainto_tsquery('simple', coalesce(q, ''))) desc, + r.created_at desc + limit lim; +$$; +``` + +--- + +## 3. 转码管线(Transcode Worker) + +### 3.1 序列图 + +```mermaid +sequenceDiagram + autonumber + participant iOS as iOS App + participant Edge as Edge Function + participant Stg as Supabase Storage + participant DB as Postgres + participant W as Transcode Worker + participant RT as Realtime Channel + participant Web as Web Client + + iOS->>Edge: POST upload-init (title, tags, bytes) + Edge->>DB: insert rooms / room_versions (status=uploading) + Edge->>Stg: sign presigned URLs (usdz + json) + Edge-->>iOS: { version_id, presigned_usdz, presigned_json } + iOS->>Stg: PUT source.usdz (direct) + iOS->>Stg: PUT source.roomplan.json (direct) + iOS->>Edge: POST upload-complete (version_id, redactions[]) + Edge->>DB: update room_versions.status = queued; insert redactions + Edge->>W: POST /enqueue { version_id, source_paths } + W->>Stg: GET source.usdz + source.roomplan.json + W->>W: usdz -> glb (usdzconvert / usd-from-gltf) + W->>W: parse roomplan.json -> 4 层节点标注 + W->>W: 生成 layer_manifest.json + W->>W: 渲染 thumbnail.webp + preview.mp4 + W->>Stg: PUT canonical.glb / layer_manifest.json / thumbnail.webp + W->>Edge: POST transcode-done (status=ready, paths, summary) + Edge->>DB: update room_versions.status=ready; rooms.current_version_id + Edge->>RT: broadcast 'room:{room_id}' { event:'ready', version_id } + RT-->>iOS: WebSocket push (用户收到"扫描已上线") + RT-->>Web: WebSocket push (列表页插入新卡片) +``` + +### 3.2 转码步骤明细 + +| 步骤 | 工具 | 输入 | 输出 | 失败码 | +|------|------|------|------|--------| +| T-1 拉取 | Supabase SDK | private bucket | 本地 `/tmp/src/` | `STORAGE_FETCH_FAILED` | +| T-2 USDZ→glTF | `usdzconvert`(Apple)+ `gltf-transform` | `source.usdz` | `intermediate.gltf` | `USDZ_DECODE_FAILED`, `GLTF_ENCODE_FAILED` | +| T-3 几何压缩 | `gltf-transform meshopt` / Draco | `intermediate.gltf` | `canonical.glb`(10–20% 原体积) | `MESH_COMPRESSION_FAILED` | +| T-4 分层标注 | 自研 Python(基于 RoomPlan JSON 的 `walls/floors/objects` semantic anchors) | `source.roomplan.json` + `canonical.glb` 节点 | 节点 ID 标注(`wall_*`, `floor_*`, `furn_*`) | `LAYER_TAG_FAILED` | +| T-5 manifest 合成 | 自研 Python | 上一步标注 | `layer_manifest.json`(通过 JSON Schema 校验) | `LAYER_MANIFEST_INVALID` | +| T-6 缩略图 | headless three.js(`puppeteer` + `webgl`)或 `gltf-transform render` | `canonical.glb` | `thumbnail.webp` (640×360, 1280×720) | `THUMBNAIL_FAILED` | +| T-7 预览动画(可选) | 同上 + ffmpeg | 5 个相机轨迹关键帧 | `preview.mp4` | 失败可容忍,不阻塞 ready | +| T-8 回写 | Supabase SDK | 产物 | `public/rooms/{id}/v{n}/` | `STORAGE_PUT_FAILED` | +| T-9 通知 | HTTP POST `transcode-done` | summary | DB + Realtime | `TRANSCODE_DONE_REJECTED` | + +### 3.3 失败重试策略 + +``` +attempt 1 fail -> wait 30s -> attempt 2 +attempt 2 fail -> wait 120s -> attempt 3 +attempt 3 fail -> status = 'failed', transcode_error 记录, 进人工队列 +``` + +- **指数退避**:30 s → 120 s → 480 s,全部失败后入 `manual_review` 队列(人工排查) +- **幂等**:同一 `version_id` 重试时,Worker 必须先清掉 `public/rooms/.../v{n}/.tmp/` 暂存目录 +- **不可重试错误**:`LAYER_MANIFEST_INVALID`(即 manifest 通不过 JSON Schema)应在 attempt 1 立即标 `failed`——多试无用 +- **可重试错误**:`STORAGE_FETCH_FAILED`、`THUMBNAIL_FAILED`、网络/超时类 +- **超时**:单次最长 5 分钟(大于 50 MB 的 USDZ 拒收,在 E-01 就该拦下) + +### 3.4 上传协调状态机 + +``` + uploading --upload-complete--> queued + | | + | (TTL 30min 未 complete) v + +--> failed transcoding + | + attempt<3 失败 | ready + v ^ + failed | + | | + manual review -+ + | + archived +``` + +`uploading` 超过 30 分钟未收到 `upload-complete` 由 Postgres `pg_cron` 定时清理为 `failed`,避免僵尸记录。 + +--- + +## 4. Remix 数据模型决策 + +### 4.1 拍板:**引用 + 覆盖层(reference + overlay),不深拷贝几何** + +> **为什么**:CrowdRoom 的内容飞轮预期 1 个房间被 N 次 remix(设计师试色场景)。若每次 remix 都深拷贝 `canonical.glb`,存储成本 O(N × room_size);用覆盖层只存 diff 是 O(N × small_diff)。代价是 Web 端要在浏览器里**实时合成**——但 Three.js 切换节点可见性 / 替换材质本来就是 60fps 操作,无运行时压力。 +> +> 对应错误码:`REMIX_PARENT_DELETED`(父版本不可硬删,必须先 tombstone)、`REMIX_PARENT_NOT_READY`(父 status ≠ ready 时不允许 remix)、`OVERLAY_INVALID`(schema 校验失败)。 + +### 4.2 `remix_overlay.json` Schema 示例 + +```json +{ + "schema_version": "1.0.0", + "parent_version_id": "c7e0...", + "parent_glb_uri": "rooms/8f1c.../v1/canonical.glb", + "parent_manifest_uri": "rooms/8f1c.../v1/layer_manifest.json", + "ops": [ + { + "op": "replace_furniture", + "target_item_id": "bed_001", + "asset_id": "a91c2...", + "asset_glb_uri": "assets/furniture/modern_bed_oak.glb", + "transform": { + "translate": [0, 0, 0], + "rotate_quat": [1, 0, 0, 0], + "scale": [1, 1, 1] + }, + "snap_to_anchor": true + }, + { + "op": "replace_material", + "target_slot_id": "mat_floor_wood", + "asset_id": "a8e92...", + "pbr_override": { + "base_color_tex": "assets/materials/oak_dark/base.webp", + "normal_tex": "assets/materials/oak_dark/normal.webp", + "roughness": 0.55, + "metallic": 0.0 + } + }, + { + "op": "hide_layer", + "layer_kind": "furniture", + "target_item_ids": ["tv_001"] + }, + { + "op": "set_wall_color", + "target_mesh_id": "wall_2", + "base_color": [0.85, 0.78, 0.92, 1.0] + } + ], + "camera_state": { + "position": [2.4, 1.6, 3.0], + "look_at": [0.0, 0.8, 0.0], + "fov_deg": 55 + } +} +``` + +### 4.3 支持的 `op` 类型 + +| op | 作用 | 校验 | +|----|------|------| +| `replace_furniture` | 隐藏父 item 的 `mesh_node_ids[]`,在 `anchor_point` 处插入 `asset_glb_uri` | `target_item_id` 必须在父 manifest furniture.items[] 中且 `replaceable=true`;`asset_id` 必须存在于 `assets` 表 `kind='furniture'` | +| `replace_material` | 把 manifest 中某个 slot 的 PBR 整体替换为新 asset | `target_slot_id` 必须在父 manifest materials.slots[] 中且 `replaceable=true` | +| `hide_layer` | 整层或某些 item 隐藏 | `layer_kind ∈ {walls, floor, furniture, materials}`;如有 `target_item_ids` 则只隐藏部分 | +| `set_wall_color` | 单墙改色,不需要换贴图 | 仅 `walls` 层 mesh 可用 | +| `add_decoration` | 在某个 anchor 旁挂额外资产(如盆栽) | 新增,不引用 target_item_id;位置走 world OBB | + +**fork 语义**:`remix-create` 端点本质是 `INSERT INTO remixes(parent_version_id, overlay)`;不复制几何、不复制 manifest——Web 端在加载 remix 时拉父 `canonical.glb` + 父 `layer_manifest.json` + 自己的 `overlay`,三者在浏览器里合成最终视图。 + +--- + +## 5. 可分享 viewState(图层切换状态) + +`view-state`(E-10)把 Web 端用户当前的"开关哪些层 + 相机视角 + 应用了哪个 overlay"打包成一个短 token,可粘到任何 IM/邮件里。 + +### 5.1 入参 + +```json +{ + "room_id": "8f1c...", + "version_id": "c7e0...", + "layer_toggles": { + "walls": true, + "floor": true, + "furniture": false, + "materials": true + }, + "camera": { + "position": [2.4, 1.6, 3.0], + "look_at": [0.0, 0.8, 0.0], + "fov_deg": 55 + }, + "overlay_id": null +} +``` + +### 5.2 出参与存储 + +```json +{ + "share_token": "vs_aB7xC9...", + "share_url": "https://crowdroom.app/r/8f1c.../?vs=vs_aB7xC9..." +} +``` + +实现:Edge Function 把 viewState body 序列化后 **gzip + base64url**,得到 `share_token`;不入 Postgres(无需持久化),TTL 由 token 自带过期戳(默认 90 天)。Web 端解码即可恢复状态。这样无需新增表,分享链接也可离线生成统计。 + +--- + +## 6. 用户配额表 + +> 配额由 [`/functions/v1/quota`](E-15) 实时返回,超额时上游端点(`upload-init` 等)抛 `QUOTA_EXCEEDED`。 + +| 维度 | 匿名用户 | 注册用户(free) | 创作者(registered + verified email) | +|------|----------|------------------|---------------------------------------| +| 总存储 | — | 500 MB | 2 GB | +| 单文件 `source.usdz` 大小 | — | 30 MB | 50 MB | +| 单文件 `canonical.glb` 大小(产出) | — | 8 MB | 15 MB | +| 月上传房间数 | — | 10 | 50 | +| 月转码分钟数 | — | 60 min | 240 min | +| 单房间版本数 | — | 5 | 20 | +| 月 Remix 数 | — | 30 | 200 | +| 评论/天 | — | 50 | 200 | +| 点赞 QPS | — | 5 /秒 | 5 /秒 | +| 公开作品数上限 | — | 30 | 200 | +| 仅可浏览(read-only) | ✅ | ✅ | ✅ | + +`quota` 返回结构: + +```json +{ + "tier": "free", + "storage": { "used_bytes": 134217728, "limit_bytes": 524288000 }, + "uploads_this_month": { "used": 4, "limit": 10 }, + "transcode_minutes_this_month": { "used": 12.4, "limit": 60 }, + "remixes_this_month": { "used": 6, "limit": 30 }, + "reset_at": "2026-06-01T00:00:00Z" +} +``` + +实现:Edge Function 用 Postgres `materialized view` 每小时刷新 `user_quota_usage`,配合实时累加(上传完成时 +1)。 + +--- + +## 7. 错误码总表 + +> 命名规则:`{DOMAIN}_{REASON}`,全部大写下划线;HTTP 状态码 + 业务码并存(业务码在 body `error.code`)。 + +### 7.1 4xx 客户端错误 + +| 业务码 | HTTP | 含义 | 触发端点 | +|--------|------|------|----------| +| `UNAUTHENTICATED` | 401 | 缺/过期 JWT | 所有 user 端点 | +| `RLS_DENIED` | 403 | RLS 拒绝(如查私有房间) | PostgREST | +| `STORAGE_FORBIDDEN` | 403 | presigned URL 失效或不匹配 | E-02/E-03 | +| `ROOM_NOT_FOUND` | 404 | 房间不存在/对该用户不可见 | E-07/E-12 | +| `MANIFEST_NOT_FOUND` | 404 | 版本未 ready,manifest 还没产出 | E-09 | +| `VERSION_NOT_FOUND` | 404 | version_id 不存在 | E-04 | +| `INVALID_TAGS` | 400 | 标签 > 8 个或含非法字符 | E-01 | +| `FILE_TOO_LARGE` | 413 | 超出配额单文件上限 | E-01 | +| `COMMENT_TOO_LONG` | 400 | body > 1000 字符 | E-13 | +| `SEARCH_QUERY_TOO_SHORT` | 400 | q 长度 < 2 | E-08 | +| `VIEW_STATE_INVALID` | 400 | layer_toggles 缺键 / camera 字段缺失 | E-10 | +| `REDACTION_INVALID` | 400 | redactions[] region 字段不通过 schema | E-04 | +| `OVERLAY_INVALID` | 400 | overlay ops 不通过 schema 或引用不存在的 item/slot | E-11 | +| `ASSET_NOT_FOUND` | 404 | overlay 引用了不存在的 asset_id | E-11 | +| `REMIX_PARENT_NOT_READY` | 409 | 父版本 status ≠ ready | E-11 | +| `REMIX_PARENT_DELETED` | 410 | 父房间/版本已 tombstone | E-11/E-09 | +| `ROOM_HAS_REMIXES` | 409 | 删除房间被 remix 引用,需先 tombstone | E-16 | +| `RATE_LIMITED` | 429 | 超过端点速率限制 | E-08/E-12/E-14 | +| `QUOTA_EXCEEDED` | 429 | 超月度/存储配额 | E-01/E-11 | +| `REPORT_DUPLICATE` | 409 | 同一 target 24h 内已被同人举报 | E-14 | +| `INVALID_TARGET` | 400 | target_type ∉ {room,remix,comment,user} | E-14 | +| `EMBED_RATE_LIMITED` | 429 | iframe 嵌入超过 Referer/IP 限流(v0.2 / G-3) | `/embed/r/{id}` 路由(详见 [`04_web_app_plan.md`](04_web_app_plan.md) §1.1 R-12 与 [`10_governance.md`](10_governance.md) §4 P-W-6) | +| `EMBED_FORBIDDEN` | 403 | iframe Referer 命中黑名单(NSFW/违规域名)或房间已关嵌入(v0.2 / G-3) | 同上 | +| `ACCOUNT_DELETION_IN_PROGRESS` | 409 | 账号已进入 T+7 不可撤阶段(v0.2 / G-3) | E-18 | +| `ACCOUNT_EXPORT_PENDING` | 409 | 同一用户已有正在进行的数据导出任务(v0.2 / G-3) | E-19 | +| `PARENT_SNAPSHOT_TRANSFER_FAILED` | 500 | 父房间硬删时 Remix 几何快照转移失败(事务已回滚)(v0.2 / G-3) | E-17 / E-18(T+30 阶段) | + +> 🔄 **v0.2 — 回写自 G-3**:上表末尾 5 条业务码为 v0.2 新增。其中 `EMBED_RATE_LIMITED` / `EMBED_FORBIDDEN` 由 Vercel Edge Middleware(或 Upstash Rate Limit)在 `/embed/r/{id}` 路由前置拦截;`PARENT_SNAPSHOT_TRANSFER_FAILED` 由 E-17 Edge Function 在事务回滚后返回;`ACCOUNT_*` 两条由 E-18 / E-19 状态机抛出。客户端遇到 `EMBED_RATE_LIMITED` 时应展示 `Retry-After` header 提示;遇到 `PARENT_SNAPSHOT_TRANSFER_FAILED` 时房间**不会**被删除(事务回滚保证),UI 应提示「Remix 快照转移失败,房间仍存在;请稍后重试或联系支持」。 + +### 7.2 5xx 服务端错误 + +| 业务码 | HTTP | 含义 | 处理 | +|--------|------|------|------| +| `STORAGE_FETCH_FAILED` | 502 | Worker 拉源文件失败 | 重试 | +| `STORAGE_PUT_FAILED` | 502 | Worker 回写失败 | 重试 | +| `EDGE_TIMEOUT` | 504 | Edge Function 超 30s | 重试一次,否则告警 | +| `WORKER_UNAVAILABLE` | 503 | Worker 队列爆满 | 客户端 60s 后重试 | +| `INTERNAL_ERROR` | 500 | 未分类异常 | Sentry 告警 | + +### 7.3 业务转码错误(5xx 但语义明确) + +| 业务码 | HTTP | 含义 | 是否可重试 | +|--------|------|------|------------| +| `USDZ_DECODE_FAILED` | 422 | usdzconvert 解析失败 | ❌ 不可(源文件损坏) | +| `GLTF_ENCODE_FAILED` | 500 | gltf-transform 编码失败 | ✅ 可 | +| `MESH_COMPRESSION_FAILED` | 500 | Draco/Meshopt 失败 | ✅ 可 | +| `LAYER_TAG_FAILED` | 500 | RoomPlan JSON anchor 与 glb 节点对不齐 | ✅ 可一次 | +| `LAYER_MANIFEST_INVALID` | 422 | manifest 不通过 JSON Schema | ❌ 不可(Worker bug) | +| `THUMBNAIL_FAILED` | 500 | headless 渲染失败 | ✅ 可,失败不阻塞 ready | +| `ROOM_TRANSCODE_FAILED` | 500 | 终态:3 次重试后仍失败 | ❌ 转人工 | +| `BAD_SIGNATURE` | 401 | `transcode-done` 没带正确 service_role 签名 | ❌ | +| `TRANSCODE_DONE_REJECTED` | 409 | DB 状态已是 ready/archived,拒绝二次回调 | ❌ | + +错误总数:**26(4xx,含 v0.2 / G-3 新增 5 条)+ 5(5xx)+ 9(转码业务码)= 40 条**(v0.1 为 35 条)。 + +### 7.4 错误响应体格式 + +```json +{ + "error": { + "code": "QUOTA_EXCEEDED", + "message": "Monthly upload limit reached (10/10).", + "details": { + "quota_kind": "uploads_this_month", + "reset_at": "2026-06-01T00:00:00Z" + }, + "request_id": "req_01J..." + } +} +``` + +--- + +## 8. 给子任务 3(iOS)/ 4(Web)的契约要点 + +### 8.1 iOS 端必须保证(X) + +| # | 契约 | 后果 | +|---|------|------| +| iOS-X1 | 上传前**必须**在端侧跑 Vision 人脸检测并把命中区域以 `kind=face` 写入 `upload-complete` 的 `redactions[]`,**贴图本身也要在端侧高斯模糊**(不依赖服务端二次脱敏) | 违反 → 公开作品里出现人脸 → 法务风险 | +| iOS-X2 | 上传走 `upload-init → 直传 Storage → upload-complete` 三步,**禁止把 .usdz 字节流塞进 Edge Function body** | 违反 → 413 / Edge Function 计费爆炸 | +| iOS-X3 | RoomPlan JSON 原样上传,**不要在端侧做坐标系变换**(Worker 统一做 +Z up → +Y up) | 违反 → manifest 节点对不齐 → `LAYER_TAG_FAILED` | +| iOS-X4 | 监听 Realtime channel `room:{room_id}` 等待 `ready` 事件,**不要轮询** `room_versions.status` | 违反 → 浪费配额 | +| iOS-X5 | `quota` 端点每次进入"扫描页"前查一次;预估失败时本地拦截,不要上传完了才看到 `QUOTA_EXCEEDED` | UX 差 | + +### 8.2 Web 端必须遵守(Y) + +| # | 契约 | 后果 | +|---|------|------| +| Web-Y1 | 渲染入口**必须**先拉 `layer_manifest.json`,按 4 层固定 ID 切换可见性;不要自己解析 .glb 节点树推断结构 | 违反 → Worker 改了节点命名就坏 | +| Web-Y2 | Remix 模式下**必须**实时合成(父 glb + 父 manifest + overlay),不要请求服务端预合成 | 违反 → 服务端没有这个端点,会 404 | +| Web-Y3 | 点赞/取消赞**必须**走 `like-toggle`(幂等 + 防刷),不要直接 `INSERT/DELETE` `likes` 表 | 违反 → RLS 不挡,但会被风控封号 | +| Web-Y4 | 分享链接 `?vs=...` 解码后**必须**对未知字段宽容(前向兼容) | 违反 → schema 升级导致旧链接失效 | +| Web-Y5 | 缩略图与 .glb 一律走 CDN URL(`/storage/v1/object/public/...`),不要发起带 JWT 的请求 | 违反 → 命中率 0% + Storage 计费暴涨 | +| Web-Y6 | 评论提交后**乐观更新**,但失败时回滚——错误码 `RLS_DENIED` 表示用户已被拉黑该房间 | 违反 → 评论"消失" | + +--- + +## 9. 本章小结 + +| 关键产出 | 一句话 | +|----------|--------| +| **API 分层 3 通道** | PostgREST 跑 CRUD + 列表搜索;Edge Function 跑上传协调/防刷/Remix;Storage 直传直读 | +| **16 个端点** | 6 PostgREST + 8 Edge Function + 2 Storage 直通 | +| **转码序列图 + 9 步流水** | usdzconvert → gltf-transform → 分层标注 → manifest → 缩略图 → 回写 → 通知;指数退避 30/120/480s,3 次后转人工 | +| **Remix 用覆盖层** | `remix_overlay.json` 5 种 op,几何永远引用父;父不可硬删,必须 tombstone | +| **配额 2 档** | free(10 房间/月)vs creator(50 房间/月);匿名只读 | +| **35 条错误码** | 4xx / 5xx / 转码业务码三类,统一 `error.code` 包装 | + +读完本章你应能: +- ✅ 直接对照表格在 Supabase Dashboard 创建 8 个 Edge Functions +- ✅ 给 Worker 团队一份"输入/输出/失败码"清单 +- ✅ 给 iOS / Web 团队各自一份 5–6 条必守契约 + +下一章 [`03_ios_app_plan.md`](03_ios_app_plan.md) 在此契约上实现 iOS 端的扫描、脱敏、上传与 Realtime 监听。 + +--- + +**章节版本**:v0.1 · 草案 +**关键收获**:CrowdRoom 的服务端 = 9 表(schema)+ 16 端点(contract)+ 1 个转码 Worker;客户端任何"奇技淫巧"(比如绕过 Edge Function 直 INSERT)都会被 RLS 或风控拦下。 diff --git a/plans/CrowdRoom/03_ios_app_plan.md b/plans/CrowdRoom/03_ios_app_plan.md new file mode 100644 index 0000000..cf2fbc9 --- /dev/null +++ b/plans/CrowdRoom/03_ios_app_plan.md @@ -0,0 +1,690 @@ +# CrowdRoom · iOS App 设计(v0.2) + +> **版本**:v0.2(2026-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-X3(JSON 原样)、iOS-X4(Realtime 订阅)、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 ms;Revision 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 处理重连与心跳 | +| 本地缓存 | **SwiftData(iOS 17)** | Core Data / Realm | 草稿、未完成上传任务、Feed 分页缓存;SwiftData 与 SwiftUI 双向绑定省胶水代码 | +| 性能/崩溃监控 | **MetricKit(系统)+ Sentry iOS SDK** | Firebase Crashlytics | MetricKit 拿 RoomPlan 期 GPU/热量数据;Sentry 与 Supabase 后端 Sentry 共享 issue 视图 | +| 深链接 | **Universal Links(apple-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 PUT,5 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 转码进度 UI(iOS-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`,扫描 5–10 分钟 | +| **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% | 70–90% | < 70% | +| 家具识别数 | ≥ 5 件 | 2–4 件 | < 2 件 | +| 房间闭合 | ✅ | ✅ | ❌ 拓扑不闭合 | +| 扫描时长 | 5–10 min | 3–5 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 ATT(App 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-6(Apple 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 scaled,MVP 不做 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 端「轻、快、合规」三件套。 \ No newline at end of file diff --git a/plans/CrowdRoom/04_web_app_plan.md b/plans/CrowdRoom/04_web_app_plan.md new file mode 100644 index 0000000..4165530 --- /dev/null +++ b/plans/CrowdRoom/04_web_app_plan.md @@ -0,0 +1,884 @@ +# CrowdRoom · Web 端设计(v0.2) + +> **版本**:v0.2(2026-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.1(reviewer 工具)与 §4 P-W-6(iframe 限流落地)。 + +> 本章承接 [`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]` | SSR(OG 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` | CSR(Supabase 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 Function(Vercel 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/ui(Radix 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 场景图组织原则 + +| 决策 | 拍板 | 理由 | +|------|------|------| +| 每层一个 `` 而非用 mesh.visible 逐个 | **是** | 切层 = 1 次 React state 变更触发 1 次 group.visible 赋值;逐 mesh 切要 N 次,浪费 | +| 节点名约定 | **沿用 manifest 中 `mesh_node_ids[]`,Worker 端已统一 `wall_* / floor_* / furn_*` 前缀** | Web 端通过 `scene.getObjectByName(nodeId)` O(1) 拿引用 | +| `` 边界 | **Canvas 内一层、AssetPicker 缩略图一层** | 渲染主场景与挑材质的网络等待互不阻塞 | +| 选中态高亮 | **额外注入 `` (drei) post-processing,不修改 mesh material** | 防止"选中后退出忘了恢复"的副作用 | +| 物理 / 灯光 | **MVP 用 ``,无物理引擎** | 真实光照成本不划算;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 | 同移动端预算 | 同上 | 同上 | 同上 | + +**预算违反时的兜底**(在 `` 外部检测 `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 压缩到 1–5 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 | +| WebXR(VR / AR) | **MVP 不做**,预留 `` 组件挂载点 | 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` | +| **不透明度滑块** | 0–100 | 整层 `material.transparent = true; .opacity = v/100` | `layers[kind].opacity: 0..1` | +| **展开 / 折叠** | ▾ / ▸ | 展开后列出该层 mesh 节点(家具显示语义标签 + 缩略图) | UI 局部 state | +| **单节点 toggle** | 👁 | 仅隐藏某一个 mesh(家具层尤其常用:藏掉电视看墙) | `layers.furniture.hiddenItems: Set` | +| **保存为视图** | ⊕ | 把当前 4 层可见性 + 相机状态打包成 "Named View",可分享/收藏 | 写入 `viewState`(见 §9) | + +### 4.3 交互细节 + +| 触发 | 反馈 | +|------|------| +| 鼠标 hover 节点行 | 3D 场景中对应 mesh 加 `` 高亮(淡黄色)+ 浮层显示尺寸/语义 | +| 点击节点行 | 相机飞到该 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 ( + + ); +} +``` + +`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 保存为 Remix(material_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 | 0–360° | 圆形旋钮 | +| 缩放 | 0.7×–1.3× | 滑块;等比,禁止非等比避免视觉怪异 | + +3D 场景中同时显示 Three.js ``(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`;每页 20,React 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 → 微信深链难题) | +| 嵌入代码 | `` 一键复制 | +| 下载缩略图 | 直接 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(材质)/ license(CC0 only 默认) | +| 卡片 | 缩略图 + 名称 + 协议 + 来源 + "Try in a room"(跳 Remix) | +| 详情弹窗 | 3D 单品预览(`` 即可,不上 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 + + + + + + + + +``` + +### 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 | +| 解码宽容 | 未知字段忽略 + warning(Web-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 | `` 与 `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 | ``(详情页 SSR 时输出) | +| 路由预取 | `` 默认开(卡片 hover 预拉 manifest) | +| Service Worker | MVP 不上 PWA;P1 可加(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 视图(`
` 列出 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 同 Safari;Android X5 fallback 到 `` | 详情页 banner 提示"用浏览器打开体验更佳" | + +--- + +## 11. MVP 范围与不做项 + +### 11.1 MVP(8 周内交付,与 [`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 自动降到 `` 静态预览 | 是否需要后端转码 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.3:viewState 只记录"可见性"开关,**不持有几何**,因此天然不泄露隐藏几何;治理章应文字明确这一点 | +| **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 fps;MVP 不做 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 截图。 diff --git a/plans/CrowdRoom/05_object_replacement_handbook.md b/plans/CrowdRoom/05_object_replacement_handbook.md new file mode 100644 index 0000000..e983b8d --- /dev/null +++ b/plans/CrowdRoom/05_object_replacement_handbook.md @@ -0,0 +1,1187 @@ +# CrowdRoom · 物品替换实施手册(v0.2) + +> **版本**:v0.2(2026-05-19)· 子任务 9 产出 +> **定位**:把分散在 [`04_web_app_plan.md`](04_web_app_plan.md) §5–§7、[`01_data_schema.md`](01_data_schema.md) §5、[`02_api_contract.md`](02_api_contract.md) §4–§5 的"物品替换"逻辑**聚合 + 深化**为一份**开发者拿来就能写代码**的实施手册。 +> +> **本手册不修改任何 v0.2 架构**——所有字段命名、op 类型、API 端点都与现有 4 份文档严格一致;任何新增能力(如 `remix-update` 端点、`bbox_filter` 检索参数)均以「**建议增量**」形式标注,落地需在下一版 02/04 文档中追认。 +> +> 文档语言:简体中文;代码与字段命名:英文(与既有契约对齐)。 + +--- + +## 0. 阅读导航 + +| 你的角色 | 重点章节 | +|---------|---------| +| 前端工程师(写交互) | §3 选中 · §4 AssetPicker · §6 4DoF gizmo · §10 自动保存 | +| 前端工程师(写算法) | §5 OBB 对齐 · §9 applyOverlay | +| 后端 / Edge Function | §2 数据流 · §8 overlay schema · §10 冲突安全 | +| 资产库 / 运营 | §4 检索维度 · §5.4 anchor 缺失兜底 · §14 与子任务 10 的接口 | +| QA / 测试 | §12 失败模式 · §13 M1 验收清单 | + +--- + +## 1. 物品替换能力总览 + +### 1.1 一句话定义 + +> **"让用户在浏览器里点击扫描得到的任一家具或材质 → 从公共资产库挑一件替换 → 系统按 OBB 自动对齐 → 用户可选 4 自由度微调 → 一键发布为 Remix。"** + +该能力是 CrowdRoom 直接回应用户原始需求 *"更换其他家具 / 更换材质"* 的核心交互,对应 [`02_api_contract.md`](02_api_contract.md) §8.2 Web-Y2 契约(浏览器内实时合成、零服务端预合成)。 + +### 1.2 5 条用户故事 + +| # | 用户故事 | 关键能力 | +|---|---------|---------| +| US-1 | **普通替换**:Alice 看到一个房间里的旧沙发,点击它 → 选个北欧风新沙发 → 自动对齐 → 发布 | §3 / §4 / §5 / §8 | +| US-2 | **批量风格化**:Bob 选中房间里所有椅子(Shift+Click),一次性换成同一系列工业风 | §7 批量 | +| US-3 | **材质混搭**:Carol 不换家具,只把墙面换成浅蓝乳胶漆 + 地面换成深色橡木 | §4 材质 tab + `op:replace_material` | +| US-4 | **AR 预览**(P2 预留):David 在 iOS Safari 把已 Remix 的房间打开 AR 视图,把新家具"摆"在自家客厅 | §14 接口预留 `viewState.ar_mode` | +| US-5 | **Remix 继续 fork**:Eve 在 Bob 的工业风 Remix 上再 fork → 把椅子又换成藤编 | overlay 的 op 是天然可叠加的 | + +### 1.3 与 v0.2 架构对照表 + +| 本手册章节 | v0.2 文档对应 | 实现关系 | +|-----------|--------------|---------| +| §1 总览 | [`04_web_app_plan.md`](04_web_app_plan.md) §6 家具替换 + §5 材质替换 | **直接引用** | +| §2 数据流 | [`04_web_app_plan.md`](04_web_app_plan.md) §7.1 Remix 端到端序列图 | **深化补充**(从 9 步扩到 27 步,纳入 raycaster/AssetPicker/对齐) | +| §3 选中机制 | [`04_web_app_plan.md`](04_web_app_plan.md) §3.2 选中态高亮 + §4.3 交互细节 | **深化补充**(补 raycaster TSX 实现、触屏长按) | +| §4 AssetPicker | [`04_web_app_plan.md`](04_web_app_plan.md) §5.4 / §8.6 资产库 | **深化补充**(补无限滚动 + bbox_filter 检索参数) | +| §5 OBB 对齐 | [`04_web_app_plan.md`](04_web_app_plan.md) §6.3 自动对齐 + [`01_data_schema.md`](01_data_schema.md) §5.2 `furnitureItem.obb/anchor_point` | **深化补充**(补完整数学 + 3 种缩放模式 + 朝向兜底) | +| §6 4DoF 微调 | [`04_web_app_plan.md`](04_web_app_plan.md) §6.4 | **深化补充**(补 TransformControls TSX + Snap 策略 + Undo) | +| §7 批量替换 | [`04_web_app_plan.md`](04_web_app_plan.md) §6.6 整体隐藏 | **新增** | +| §8 写 overlay | [`02_api_contract.md`](02_api_contract.md) §4.2 `remix_overlay.json` schema | **直接引用 + 补 TS 类型** | +| §9 applyOverlay | [`04_web_app_plan.md`](04_web_app_plan.md) §5.3 / §6.3 实时预览伪代码 | **深化补充**(补完整 switch + clone 副本策略) | +| §10 自动保存 / 冲突 | [`04_web_app_plan.md`](04_web_app_plan.md) §7.2 自动保存与冲突 | **深化补充**(补 IndexedDB schema + BroadcastChannel + 父删兜底) | +| §11 性能 | [`04_web_app_plan.md`](04_web_app_plan.md) §3.3 / §10.1 | **深化补充**(补 LRU + 图层独显) | +| §12 失败模式 | [`02_api_contract.md`](02_api_contract.md) §7 错误码 + [`04_web_app_plan.md`](04_web_app_plan.md) §7.4 | **深化补充**(10 种失败情形表) | +| §13 M1 验收 | [`04_web_app_plan.md`](04_web_app_plan.md) §11.1 MVP | **新增** | +| §14 P2 预留 | [`04_web_app_plan.md`](04_web_app_plan.md) §11.2 不做项 | **新增**(CRDT / AI / AR 三条接口 hook) | + +> 全表 13 行,**全部能在 v0.2 现有文档找到锚点**——本手册不引入任何字段级破坏性改动。 + +--- + +## 2. 数据流总览(最关键章节) + +下面这张 sequenceDiagram **共 27 个步骤**,串联从"用户点击 mesh"到"跳转 Remix 详情页"的完整链路,是本手册其它章节的总纲。 + +```mermaid +sequenceDiagram + autonumber + participant U as 用户 + participant R3F as R3F Canvas + participant Z as Zustand Store + participant Mfst as layer_manifest + participant Picker as AssetPicker + participant API as PostgREST Edge + participant CDN as CDN + participant IDB as IndexedDB + participant Edge as Edge Function + participant Worker as Thumb Worker + + U->>R3F: pointerdown on mesh + R3F->>R3F: raycaster intersectObjects + R3F->>Mfst: 查 mesh_node_id -> item_id semantic_class + Mfst-->>R3F: bed_001 semantic bed obb anchor + R3F->>Z: setSelectedItem bed_001 + Z->>R3F: Outline shader 高亮选中物 + U->>Picker: 打开 AssetPicker 抽屉 + Picker->>Z: 读 selectedItem.semantic_class.obb + Picker->>API: GET assets kind furniture semantic bed bbox_filter + API-->>Picker: 12 items 分页 + U->>Picker: 选中目标资产 a91c + Picker->>CDN: GET assets furniture modern_bed_oak glb + CDN-->>Picker: glb bytes draco compressed + Picker->>R3F: useGLTF.preload 完成 + R3F->>R3F: alignAssetToOBB 计算 transform + R3F->>R3F: scene clone 副本 apply transform + R3F->>R3F: 隐藏原 furn_bed_001 visible false + R3F->>Z: pushOp replace_furniture + Z->>IDB: 写本地 draft overlay + Z->>Z: debounce 3s + Z->>Edge: PATCH remix-update overlay + Edge-->>Z: 200 last_saved + U->>R3F: 调 Y 旋转 gizmo + R3F->>Z: updateOpTransform rotation_y_deg 15 + U->>Edge: POST remix-publish + Edge->>Worker: enqueue thumbnail viewState + Worker->>CDN: PUT thumbnail webp + Edge-->>U: 跳转 r remix_id +``` + +### 2.1 关键时序约束 + +| # | 约束 | 出处 | +|---|------|------| +| C-1 | 第 2 步 raycaster 必须只命中 `selectable=true` 的 mesh(隐藏层 mesh 不可点) | §3.2 | +| C-2 | 第 4 步查 manifest 是 **O(1)**:Worker 端已建好 `mesh_node_id → item_id` map([`01_data_schema.md`](01_data_schema.md) §3.4 layers 表 `manifest_node` 冗余存储) | [`04_web_app_plan.md`](04_web_app_plan.md) §3.2 | +| C-3 | 第 11 步 AssetPicker 拉资产必须**带 `bbox_filter`**(按选中物 OBB ±20% 推荐,§4.2) | 本手册新增 | +| C-4 | 第 16–19 步 transform + clone + 隐藏原节点 → push op **必须在一个 Zustand action 内原子提交**,否则 Undo 会断 | §7 / §10 | +| C-5 | 第 21 步 debounce 3 秒由 Zustand middleware 实现,与 [`04_web_app_plan.md`](04_web_app_plan.md) §7.2 自动保存一致 | §10 | +| C-6 | 第 24 步发布前必须先把所有 pending op flush 到服务端(防 publish 与 update 竞态) | §10 | + +--- + +## 3. 选中机制(Raycaster + Node 高亮) + +### 3.1 命中策略 + +Three.js Raycaster 在 R3F 中默认开启 `recursive=true`,但 CrowdRoom 需要额外约束: + +| 规则 | 实现 | +|------|------| +| **R-3-1** 仅 `selectable=true` 的 mesh 响应 | 在场景图构建时给每个 mesh 加 `userData.selectable: boolean`,由 manifest 中 `replaceable` 字段决定([`01_data_schema.md`](01_data_schema.md) §5.2)| +| **R-3-2** 半透明墙体不阻挡背后家具点击 | Raycaster 命中后按 `intersect.distance` + `mesh.userData.opacity < 0.95 ? skip : pick` 二次过滤;首选项默认开启 | +| **R-3-3** 已锁定层(`layers[kind].locked`)的 mesh 不可点 | 在 R3F `` 上设 `userData.locked`,raycaster pre-filter | +| **R-3-4** 高亮**不修改** mesh material | 用 `@react-three/postprocessing` 的 `` 选中物入参,零副作用([`04_web_app_plan.md`](04_web_app_plan.md) §3.2) | +| **R-3-5** Hover 与 Click 区分 | `pointermove` 节流 30ms,命中时仅染色(emissive 0.15 + Outline 弱);`pointerdown` 才进选中态 | + +### 3.2 TSX 实现骨架(45 行) + +```tsx +// components/editor/useSelectableMesh.ts +"use client"; +import { useEffect, useRef } from "react"; +import { useThree } from "@react-three/fiber"; +import { Raycaster, Vector2, Mesh } from "three"; +import { useEditorStore } from "@/stores/editor-store"; + +export function useSelectableMesh() { + const { camera, scene, gl } = useThree(); + const raycaster = useRef(new Raycaster()); + const ndc = useRef(new Vector2()); + const setSelection = useEditorStore((s) => s.setSelection); + const toggleSelection = useEditorStore((s) => s.toggleSelection); + const lookupItem = useEditorStore((s) => s.lookupItemByMeshNode); + + useEffect(() => { + const canvas = gl.domElement; + const onPointerDown = (e: PointerEvent) => { + const rect = canvas.getBoundingClientRect(); + ndc.current.set( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + -((e.clientY - rect.top) / rect.height) * 2 + 1, + ); + raycaster.current.setFromCamera(ndc.current, camera); + const hits = raycaster.current.intersectObjects(scene.children, true); + const picked = hits.find((h) => { + const o = h.object as Mesh; + return o.userData.selectable === true && !o.userData.locked + && (o.userData.opacity ?? 1) >= 0.95; + }); + if (!picked) { if (!e.shiftKey) setSelection([]); return; } + const item = lookupItem(picked.object.name); + if (!item) return; + if (e.shiftKey) toggleSelection(item.item_id); + else setSelection([item.item_id]); + }; + canvas.addEventListener("pointerdown", onPointerDown); + return () => canvas.removeEventListener("pointerdown", onPointerDown); + }, [camera, scene, gl, setSelection, toggleSelection, lookupItem]); +} +``` + +> 配套:`` 在 `` 内挂载,根据 `useEditorStore` 中的 `selectedItemIds → Object3D[]` 反查实现高亮。 + +### 3.3 触屏 UX + +| 触发 | 行为 | +|------|------| +| **长按 500 ms** | 等效桌面端 `Shift+Click`,进入多选模式(haptic 提示) | +| **双击 mesh** | 相机 fit-to-bbox 到该 mesh,0.6s 缓动([`04_web_app_plan.md`](04_web_app_plan.md) §3.5)| +| **双指捏合** | 缩放相机,不触发选中 | +| **三指拖** | 平移相机(绕过 OrbitControls 默认两指) | + +实现:用 `@use-gesture/react` 的 `useDrag` + `useLongPress`;触屏 hitbox 在 §11 性能章再放大 1.5×。 + +--- + +## 4. AssetPicker UI 与资产检索 + +### 4.1 弹出形式决策 + +| 选项 | 优劣 | 决策 | +|------|------|------| +| **右抽屉**(drawer) | 不打断 3D 浏览、可与场景同时可见 | ✅ **采用** | +| 模态对话框(modal) | 居中、聚焦感强 | ❌ 遮挡 3D 场景,无法实时预览 | +| 浮窗(popover) | 轻量 | ❌ 列表/筛选信息密度承不下 | + +抽屉宽度:桌面 420 px(与图层面板 320 px 共占右侧 740 px),平板 360 px,移动端折叠为底部 sheet(高度 65% 视口)。 + +### 4.2 筛选维度(即时联动) + +| 维度 | 默认值 | 实现 | 来源 | +|------|--------|------|------| +| `semantic_class` | 自动锁定为选中物 `semantic_class` | Disabled chip,可点 `[+]` 放宽到"全部家具" | [`01_data_schema.md`](01_data_schema.md) §5.2 16 类 enum | +| `style` tag | 全部 | 多选 chip:北欧/工业/中式/极简/复古/侘寂/包豪斯 | [`01_data_schema.md`](01_data_schema.md) §3.9 `assets.tags[]` | +| `color` | 全部 | HSV 色环 picker(react-colorful),映射到最近 12 色 chip | 资产侧元数据 `dominant_color` | +| `bbox_filter` | 选中物 OBB 体积 **±20%** | 切换开关;关闭后允许大尺寸不匹配,UI 给警告 | 本手册新增(建议增量) | +| `license` | CC0 only(MVP 默认) | Toggle "包含 CC-BY" | [`01_data_schema.md`](01_data_schema.md) §3.9 `assets.license` | + +> 🔄 **建议增量**:[`02_api_contract.md`](02_api_contract.md) §2.1 PostgREST 端点应支持 `bbox_filter=0.8,1.2`、`style=北欧&style=极简`、`dominant_color=#a4b5c6&color_tol=0.15` 复合 query;后端用 `assets` 表新增 `volume_m3 numeric` 物化列 + GIN 索引 `tags`、新增 `dominant_color text`。落地需在下一版 02 文档追认。 + +### 4.3 列表项展示 + +``` +┌────────────────────────────────────┐ +│ ┌──────┐ Modern Bed Oak │ +│ │ │ semantic: bed │ +│ │ thmb │ 4.2k tris · CC0 │ +│ │ │ by @ikea_clone │ +│ └──────┘ size 2.05x0.65x1.48 m │ +└────────────────────────────────────┘ +``` + +字段:缩略图(128×128 webp)/ 名称 / 协议 chip(CC0 绿色 / CC-BY 黄色)/ 三角面数 / 创作者 handle / OBB 尺寸(对比原物)。 + +### 4.4 无限滚动 + 虚拟列表 + +- 用 `@tanstack/react-virtual` v3,每行高 96 px,overscan 5 +- 分页:`?limit=24&offset={page*24}`;React Query `useInfiniteQuery` +- 触发阈值:滚到列表底部 200 px 时预取下一页 +- 总数 > 200 时顶部固定显示"共 N 件,按相关度排序" + +### 4.5 资产卡片 TSX 骨架(22 行) + +```tsx +// components/editor/AssetCard.tsx +import Image from "next/image"; +import type { Asset } from "@/types/asset"; +import { Badge } from "@/components/ui/badge"; + +export function AssetCard({ asset, onPick }: { asset: Asset; onPick: (a: Asset) => void }) { + return ( + + ); +} +``` + +--- + +## 5. OBB 对齐算法(重点章节) + +物品替换的算法核心:把一个新 asset(其几何来自 `assets.glb_path`)放到原家具 OBB 描述的位置/朝向/尺寸上,**默认无需用户调整**即视觉合理。 + +### 5.1 输入数据约定(与 v0.2 schema 严格对齐) + +#### 5.1.1 原 mesh 侧 + +来自父房间 `layer_manifest.json` 的 `furnitureItem`([`01_data_schema.md`](01_data_schema.md) §5.2): + +```typescript +interface OrientedBoundingBox { + center: [number, number, number]; // world meters + extent: [number, number, number]; // **full** extent (NOT halfExtents); 总长宽高 + quat: [number, number, number, number]; // [w, x, y, z],世界系朝向 +} +interface FurnitureItem { + item_id: string; + mesh_node_ids: string[]; + obb: OrientedBoundingBox; + anchor_point: [number, number, number]; // **world** position;通常是 OBB 底面中心 + semantic_class: string; + replaceable: boolean; +} +``` + +> ⚠️ **与任务描述的术语调和**:任务描述里写 `halfExtents`,v0.2 schema 实际是 `extent`(full)。本手册一律以 v0.2 `extent` 为准,凡需 `halfExtent` 时显式 `extent[i]/2`。 + +#### 5.1.2 新 asset 侧 + +来自资产库([`01_data_schema.md`](01_data_schema.md) §3.9 `assets.pbr` jsonb,由子任务 10 落地): + +```typescript +interface AssetMeta { + asset_id: string; + glb_uri: string; + anchor_local: [number, number, number]; // local meters,通常 = 底面中心 = [0, -ext_y/2, 0] + forward_axis: "+X" | "-X" | "+Y" | "-Y" | "+Z" | "-Z"; // asset 朝外的轴 + up_axis: "+X" | "-X" | "+Y" | "-Y" | "+Z" | "-Z"; // asset 朝上的轴(一般 +Y) + bbox_local: { min: [number, number, number]; max: [number, number, number] }; +} +``` + +### 5.2 对齐公式(每一步显式写出) + +设: +- 原 OBB:`C_o ∈ ℝ³`(center, world)、`E_o ∈ ℝ³⁺`(full extent)、`Q_o ∈ ℍ`(orientation quaternion `[w,x,y,z]`) +- 原 anchor:`A_o ∈ ℝ³`(world,通常 = OBB 底面中心,即 `C_o − Q_o · (0, E_o.y/2, 0)`) +- 新 asset 局部 anchor:`A_a ∈ ℝ³`(local) +- 新 asset 局部 bbox 尺寸:`E_a = bbox_local.max − bbox_local.min` +- 新 asset forward 轴单位向量 `f_a ∈ ℝ³`(如 `+Z` → `(0,0,1)`) +- asset 模型坐标系约定的"正向 forward" = `+Z`(家具工业惯例) + +**Step 1:缩放因子 `s`**(按 §5.3 模式选择) + +| 模式 | 公式 | +|------|------| +| `fit-volume`(默认) | `s = ((E_o.x · E_o.y · E_o.z) / (E_a.x · E_a.y · E_a.z))^(1/3)` | +| `fit-floor` | `s = sqrt((E_o.x · E_o.z) / (E_a.x · E_a.z))`(底面投影面积,Y 不参与)| +| `none` | `s = 1` | + +边界 sanity:若 `s < 0.1` 或 `s > 10`,**警告并 clamp 到 [0.1, 10]**。 + +**Step 2:朝向四元数 `Q_final`** + +``` +Q_canonical = quaternionFromUnitVectors(f_a, (0,0,1)) // asset 模型空间 -> "+Z forward" 标准 +Q_final = Q_o ⊗ Q_canonical // 四元数复合,非可交换 +``` + +`up_axis` 同理二次校正一次(防止 asset 倒置)。 + +**Step 3:位置 `P_world`** + +让 asset 局部 anchor(经缩放与旋转后)落到原 anchor `A_o`: + +``` +A_a_rotated_scaled = Q_final · (s · A_a) // local 向量 -> world 方向(不含位移) +P_world = A_o − A_a_rotated_scaled +``` + +**Step 4:写回 Three.js** + +``` +assetGroup.position.set(...P_world) +assetGroup.quaternion.set(Q_final.x, Q_final.y, Q_final.z, Q_final.w) +assetGroup.scale.set(s, s, s) +assetGroup.updateMatrixWorld(true) +``` + +### 5.3 三种缩放模式 + +| 模式 | 适用 | 视觉效果 | +|------|------|---------| +| **fit-volume**(默认) | 通用,沙发/床/柜 | 体积匹配;高瘦物可能略胖 | +| **fit-floor** | 沙发/床/餐桌——"高度自由,占地匹配" | 底面与原物贴合,高度按 asset 原始 | +| **none** | 灯具/电视/装饰物——"原尺寸即可" | 不缩放;用户必要时手动调 §6 | + +UI:AssetPicker 下方一个 segmented control,默认按 `semantic_class` 智能推荐: + +``` +bed / sofa / table / chair -> fit-floor +storage / refrigerator / stove -> fit-volume +television / fireplace / stairs -> none +``` + +### 5.4 TypeScript 实现骨架(76 行) + +```typescript +// lib/editor/align-obb.ts +import { Quaternion, Vector3 } from "three"; + +export type AlignmentMode = "fit-volume" | "fit-floor" | "none"; + +export interface OBB { + center: [number, number, number]; + extent: [number, number, number]; // full extent + quat: [number, number, number, number]; // [w, x, y, z] +} +export interface AssetMeta { + anchor_local: [number, number, number]; + forward_axis: "+X" | "-X" | "+Y" | "-Y" | "+Z" | "-Z"; + up_axis: "+X" | "-X" | "+Y" | "-Y" | "+Z" | "-Z"; + bbox_local: { min: [number, number, number]; max: [number, number, number] }; +} +export interface AlignResult { + position: [number, number, number]; + quaternion: [number, number, number, number]; // [x, y, z, w] (Three.js 顺序) + scale: [number, number, number]; + warnings: string[]; +} + +const AXIS_VEC: Record = { + "+X": new Vector3( 1, 0, 0), "-X": new Vector3(-1, 0, 0), + "+Y": new Vector3( 0, 1, 0), "-Y": new Vector3( 0,-1, 0), + "+Z": new Vector3( 0, 0, 1), "-Z": new Vector3( 0, 0,-1), +}; + +export function alignAssetToOBB( + asset: AssetMeta, + target: OBB, + anchorWorld: [number, number, number], + mode: AlignmentMode = "fit-volume", +): AlignResult { + const warnings: string[] = []; + const E_o = new Vector3(...target.extent); + const E_a = new Vector3( + asset.bbox_local.max[0] - asset.bbox_local.min[0], + asset.bbox_local.max[1] - asset.bbox_local.min[1], + asset.bbox_local.max[2] - asset.bbox_local.min[2], + ); + + // Step 1: scale + let s = 1; + if (mode === "fit-volume") { + s = Math.cbrt((E_o.x * E_o.y * E_o.z) / Math.max(1e-6, E_a.x * E_a.y * E_a.z)); + } else if (mode === "fit-floor") { + s = Math.sqrt((E_o.x * E_o.z) / Math.max(1e-6, E_a.x * E_a.z)); + } + if (s < 0.1 || s > 10) { + warnings.push(`scale ${s.toFixed(2)}x clamped to [0.1, 10]`); + s = Math.min(10, Math.max(0.1, s)); + } + + // Step 2: orientation (wxyz -> xyzw for Three.js) + const Q_o = new Quaternion(target.quat[1], target.quat[2], target.quat[3], target.quat[0]); + const f_a = AXIS_VEC[asset.forward_axis].clone(); + const Q_canonical = new Quaternion().setFromUnitVectors(f_a, new Vector3(0, 0, 1)); + const Q_final = Q_o.clone().multiply(Q_canonical); + // up_axis 二次校正:axis-pair lookup table(实现略;非 +Y 时叠加一次 90° 旋转) + + // Step 3: position + const A_a = new Vector3(...asset.anchor_local).multiplyScalar(s).applyQuaternion(Q_final); + const P = new Vector3(...anchorWorld).sub(A_a); + + return { + position: [P.x, P.y, P.z], + quaternion: [Q_final.x, Q_final.y, Q_final.z, Q_final.w], + scale: [s, s, s], + warnings, + }; +} +``` + +### 5.5 失败兜底 + +| 情况 | 检测 | 兜底行为 | +|------|------|---------| +| asset 缺 `anchor_local` | `asset.anchor_local === undefined` | fallback 到 `bbox_local` 中心;UI toast "该资产未标注锚点,对齐结果可能偏移" | +| asset 缺 `forward_axis` | 同上 | 默认 `+Z`;UI 显示"⚠ 朝向未知,可一键 180° 翻转" | +| 原 OBB `extent.y < 0.01` | 扁平 mesh | 走 `fit-floor` 模式;记录 Sentry breadcrumb | +| 朝向冲突(沙发背对墙) | 用户视觉判断 | UI 提供 **"⟲ 180° 翻转"** 按钮:`Q_final ← Q_final ⊗ quat_y(180°)` | +| 缩放 clamp 命中 | `warnings.length > 0` | 红色 banner + 高亮 §6 gizmo 让用户手调 | + +--- + +## 6. 4 自由度微调 UI + +### 6.1 Gizmo 三态 + +| 模式 | 自由度 | 快捷键 | +|------|--------|--------| +| **Translate** | X / Y / Z 平移 | `T` | +| **Rotate** | 仅绕 Y 轴(防"飘起来" / "贴墙穿模") | `R` | +| **Scale** | 等比(uniform) | `S` | + +> ⚠️ 与 [`04_web_app_plan.md`](04_web_app_plan.md) §6.4 一致:**禁止任意 6DoF 旋转**和**非等比缩放**。 + +### 6.2 TransformControls TSX 骨架(30 行) + +```tsx +// components/editor/EditGizmo.tsx +"use client"; +import { TransformControls } from "@react-three/drei"; +import type { Object3D } from "three"; +import { useEditorStore } from "@/stores/editor-store"; + +export function EditGizmo({ target }: { target: Object3D | null }) { + const mode = useEditorStore((s) => s.gizmoMode); + const snapEnabled = useEditorStore((s) => s.snapEnabled); + const updateTransform = useEditorStore((s) => s.updateActiveOpTransform); + if (!target) return null; + const snap = { + translate: snapEnabled ? 0.05 : null, + rotate: snapEnabled ? Math.PI / 12 : null, // 15° + scale: snapEnabled ? 0.05 : null, + }; + return ( + updateTransform({ + position: target.position.toArray() as [number, number, number], + rotation_y_deg: (target.rotation.y * 180) / Math.PI, + scale_uniform: target.scale.x, + })} + /> + ); +} +``` + +### 6.3 数值输入面板(与 gizmo 双向绑定) + +抽屉底部 4 列表单,**精度 0.01 m**: + +``` +X: [ -0.03 ] m Y: [ 0.00 ] m Z: [ +0.12 ] m +Rot Y: [ 15.0 ] deg Scale: [ 1.04 ] x +[ Reset ] [ Center ] +``` + +- `react-hook-form` 受控;防抖 200 ms 写 store +- gizmo 拖动 → store 变更 → 表单 `setValue`(不触发 `onChange`,避免循环) +- 表单输入 → store 变更 → `target.position.set(...)`(手动同步 Object3D) + +### 6.4 Snap 策略 + +| 自由度 | 默认 snap | 修饰键 | +|--------|----------|--------| +| Translate | 0.05 m | `Shift` → 0.01 m;`Alt` → 关 snap | +| Rotate Y | 15° | `Shift` → 5°;`Alt` → 自由 | +| Scale | 0.05× | `Shift` → 0.01× | + +### 6.5 Undo / Redo + +| 项 | 决策 | +|----|------| +| 库 | **`zundo`**(Zustand temporal middleware,10 KB) | +| 栈深 | 50 步(与 [`04_web_app_plan.md`](04_web_app_plan.md) §7.3 一致) | +| 粒度 | **每个用户动作 = 1 步**(拖 gizmo 全程算 1 步,松手时 commit) | +| 快捷键 | `Cmd/Ctrl+Z` undo · `Cmd/Ctrl+Shift+Z` redo | +| 批量操作 | §7 批量 N 个 op **包在 `temporal.pause()`...`resume()` 内**,算单步 | + +--- + +## 7. 多个物品的批量替换("全屋换风格") + +### 7.1 流程 + +1. 用户 `Shift+Click` 选中 ≥ 2 个家具 mesh(§3.2 已支持) +2. AssetPicker 顶部出现"批量替换 (N 个已选)"banner,按 `semantic_class` 多选模式过滤 +3. 用户选风格 tag(如"工业风")→ 列表展示该风格下覆盖所有所选 semantic_class 的资产组合 +4. 点击"应用到全部 N 个"→ Zustand action 原子提交 N 个 `replace_furniture` op +5. 单步 Undo 即可整体回滚 + +### 7.2 原子提交 + +```typescript +// stores/editor-store.ts +applyBatchReplacement: (selections: ItemId[], assets: AssetMeta[]) => { + const { temporal } = get(); + temporal.pause(); + try { + selections.forEach((itemId, i) => { + const asset = assets[i]; + const aligned = alignAssetToOBB(asset, getOBB(itemId), getAnchor(itemId)); + get().pushOp({ + op: "replace_furniture", + target_item_id: itemId, + asset_id: asset.asset_id, + asset_glb_uri: asset.glb_uri, + transform: aligned, + snap_to_anchor: true, + }); + }); + } finally { + temporal.resume(); // 此时 50 步栈只多了 1 步 + } +} +``` + +### 7.3 风格预设(P2 候选) + +MVP **不实现** "一键全屋北欧风" 的服务端预设;但本手册在 `remix_overlay.json` 中**预留字段** `style_preset_id?: string`(§8.3 类型定义已包含),落地路径见 [`ROADMAP.md`](ROADMAP.md) F-X 候选。 + +--- + +## 8. 写入 `remix_overlay.json` + +### 8.1 与 v0.2 既有 schema 的关系 + +[`02_api_contract.md`](02_api_contract.md) §4.2 已定义 `remix_overlay.json` 的整体形态与 5 种 op:`replace_furniture / replace_material / hide_layer / set_wall_color / add_decoration`。本手册**完全遵循**这套命名(任务描述里的 `swap_furniture / hide_node` 是同义别名,本手册一律用 v0.2 官方命名)。 + +### 8.2 完整示例 + +```json +{ + "schema_version": "1.0.0", + "parent_version_id": "c7e0d8f1-2a4d-6b9e-4f0e-8a7c3d2b1f5e", + "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": "a91c2b3e-...", + "asset_glb_uri": "assets/furniture/modern_bed_oak.glb", + "transform": { + "translate": [0.12, 0.0, -0.45], + "rotate_quat": [0.991, 0.0, 0.131, 0.0], + "scale": [1.04, 1.04, 1.04] + }, + "snap_to_anchor": true, + "alignment_mode": "fit-volume", + "created_at": "2026-05-19T12:34:56Z" + }, + { + "op": "replace_material", + "target_slot_id": "mat_floor_wood", + "asset_id": "a8e92c1f-...", + "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 + }, + "uv_scale": [2.0, 2.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] + } + ], + "style_preset_id": null, + "camera_state": { + "position": [2.4, 1.6, 3.0], + "look_at": [0.0, 0.8, 0.0], + "fov_deg": 55 + } +} +``` + +### 8.3 TypeScript 完整类型定义 + +```typescript +// types/remix-overlay.ts +export type LayerKind = "walls" | "floor" | "furniture" | "materials"; +export type AlignmentMode = "fit-volume" | "fit-floor" | "none"; + +export interface OpReplaceFurniture { + op: "replace_furniture"; + target_item_id: string; + asset_id: string; + asset_glb_uri: string; + transform: { + translate: [number, number, number]; + rotate_quat: [number, number, number, number]; // [w, x, y, z] + scale: [number, number, number]; + }; + snap_to_anchor: boolean; + alignment_mode?: AlignmentMode; + created_at?: string; // ISO 8601 +} + +export interface OpReplaceMaterial { + op: "replace_material"; + target_slot_id: string; + asset_id: string; + pbr_override: { + base_color?: [number, number, number, number]; // RGBA 0-1 + base_color_tex?: string; + normal_tex?: string; + roughness?: number; + metallic?: number; + ao_tex?: string; + }; + uv_scale?: [number, number]; +} + +export interface OpHideLayer { + op: "hide_layer"; + layer_kind: LayerKind; + target_item_ids?: string[]; // 缺省 = 整层;有值 = 仅隐藏部分 +} + +export interface OpSetWallColor { + op: "set_wall_color"; + target_mesh_id: string; + base_color: [number, number, number, number]; +} + +export interface OpAddDecoration { + op: "add_decoration"; + asset_id: string; + asset_glb_uri: string; + world_obb: { + center: [number, number, number]; + extent: [number, number, number]; + quat: [number, number, number, number]; + }; +} + +export type OverlayOp = + | OpReplaceFurniture + | OpReplaceMaterial + | OpHideLayer + | OpSetWallColor + | OpAddDecoration; + +export interface RemixOverlay { + schema_version: "1.0.0"; + parent_version_id: string; + parent_glb_uri: string; + parent_manifest_uri: string; + ops: OverlayOp[]; + style_preset_id?: string | null; + camera_state?: { + position: [number, number, number]; + look_at: [number, number, number]; + fov_deg: number; + }; +} +``` + +### 8.4 大小预算 + +| 单 op 类型 | 典型字节数 | +|------------|-----------| +| `replace_furniture` | ~360 B | +| `replace_material` | ~280 B | +| `hide_layer` | ~80 B | +| `set_wall_color` | ~100 B | +| `add_decoration` | ~280 B | + +**预算**:100 次操作 ≈ 100 × 平均 230 B ≈ **23 KB**,加 schema 包裹 ≤ **50 KB**(vs 父 `canonical.glb` 5–10 MB,**两个数量级压缩**)。超过 50 KB 触发警告 toast(§12 F-7);超过 1 MB 拒绝 PATCH。 + +### 8.5 命名映射表(任务描述别名 → v0.2 实际字段) + +| 任务描述用词 | v0.2 实际字段 | 本手册使用 | +|------------|--------------|-----------| +| `swap_furniture` | `replace_furniture` | **`replace_furniture`** | +| `hide_node` | `hide_layer` + `target_item_ids` | **`hide_layer`** | +| `target_node_id` (家具) | `target_item_id` | **`target_item_id`** | +| `target_node_id` (材质) | `target_slot_id` | **`target_slot_id`** | +| `rotation_y_deg / scale_uniform` | `rotate_quat / scale` | 运行时 deg/uniform → 写入 overlay 时序列化为 `rotate_quat / scale` | + +--- + +## 9. 实时合成(浏览器内,无服务端预合成) + +呼应 [`02_api_contract.md`](02_api_contract.md) §8.2 Web-Y2 契约:**父 .glb + 父 manifest + overlay 在浏览器内合成**,不请求服务端预合成。 + +### 9.1 应用顺序(关键) + +``` +1. clone 父 scene graph(编辑器入口做 1 次;保证父几何永不被修改) +2. 顺序遍历 overlay.ops: + 2.1 op = hide_layer -> 对应 group / 节点 visible = false + 2.2 op = set_wall_color -> 找 mesh -> material.color.set(hex) + 2.3 op = replace_material -> 找 slot.target_mesh -> material 替换 PBR + 2.4 op = replace_furniture-> 隐藏原 item.mesh_node_ids -> load asset -> apply transform + 2.5 op = add_decoration -> load asset -> 按 world_obb 摆放 +3. 应用 camera_state 到 OrbitControls +``` + +> ✅ **强制约束**:父 scene graph 是 React 渲染缓存的引用,**必须**用 `scene.clone(true)` 拿到深拷贝再修改;否则用户"取消 Remix"时无法回退到父原貌。`useGLTF` 返回的 `gltf.scene` 是共享单例,不能直接改。 + +### 9.2 完整 TypeScript 实现(55 行) + +```typescript +// lib/editor/apply-overlay.ts +import { Group, Mesh, MeshStandardMaterial, Color, Quaternion, Vector3 } from "three"; +import type { GLTF } from "three/examples/jsm/loaders/GLTFLoader.js"; +import type { RemixOverlay } from "@/types/remix-overlay"; + +export interface OverlayContext { + parentScene: Group; // 已 clone 的父 scene(不会被修改其源引用) + manifestNodeMap: Map; // mesh_node_id -> Mesh,构建时一次性建好 + itemMeshMap: Map; // item_id -> mesh_node_ids + slotMeshMap: Map; // slot_id -> target_mesh_id + loadGLTF: (uri: string) => Promise; // 注入 useGLTF.preload 或自定义 loader + textureLoader: (uri: string) => Promise; +} + +export async function applyOverlay(overlay: RemixOverlay, ctx: OverlayContext): Promise { + for (const op of overlay.ops) { + switch (op.op) { + case "hide_layer": { + if (op.target_item_ids?.length) { + for (const itemId of op.target_item_ids) { + for (const nodeId of ctx.itemMeshMap.get(itemId) ?? []) { + const m = ctx.manifestNodeMap.get(nodeId); if (m) m.visible = false; + } + } + } else { + // 整层隐藏:父 group 上设置 visible + const layerGroup = ctx.parentScene.getObjectByName(`layer_${op.layer_kind}`); + if (layerGroup) layerGroup.visible = false; + } + break; + } + case "set_wall_color": { + const m = ctx.manifestNodeMap.get(op.target_mesh_id); + const mat = m?.material as MeshStandardMaterial | undefined; + if (mat) mat.color = new Color(op.base_color[0], op.base_color[1], op.base_color[2]); + break; + } + case "replace_material": { + const meshId = ctx.slotMeshMap.get(op.target_slot_id); if (!meshId) break; + const mesh = ctx.manifestNodeMap.get(meshId); if (!mesh) break; + const mat = mesh.material as MeshStandardMaterial; + if (op.pbr_override.base_color_tex) mat.map = await ctx.textureLoader(op.pbr_override.base_color_tex); + if (op.pbr_override.normal_tex) mat.normalMap = await ctx.textureLoader(op.pbr_override.normal_tex); + if (op.pbr_override.roughness !== undefined) mat.roughness = op.pbr_override.roughness; + if (op.pbr_override.metallic !== undefined) mat.metalness = op.pbr_override.metallic; + mat.needsUpdate = true; + break; + } + case "replace_furniture": { + for (const nodeId of ctx.itemMeshMap.get(op.target_item_id) ?? []) { + const m = ctx.manifestNodeMap.get(nodeId); if (m) m.visible = false; + } + const gltf = await ctx.loadGLTF(op.asset_glb_uri); + const inst = gltf.scene.clone(true); + inst.position.set(...op.transform.translate); + inst.quaternion.set(op.transform.rotate_quat[1], op.transform.rotate_quat[2], op.transform.rotate_quat[3], op.transform.rotate_quat[0]); + inst.scale.set(...op.transform.scale); + inst.name = `overlay_furniture_${op.target_item_id}`; + ctx.parentScene.add(inst); + break; + } + case "add_decoration": { + const gltf = await ctx.loadGLTF(op.asset_glb_uri); + const inst = gltf.scene.clone(true); + inst.position.set(...op.world_obb.center); + inst.quaternion.set(op.world_obb.quat[1], op.world_obb.quat[2], op.world_obb.quat[3], op.world_obb.quat[0]); + ctx.parentScene.add(inst); + break; + } + } + } +} +``` + +### 9.3 性能目标 + +| 场景 | 目标 | 达成措施 | +|------|------|---------| +| 单 op 应用 | < 5 ms(隐藏 / 改色 / 改材质) | manifest map O(1) 查找;material 直接改属性不重建 mesh | +| 单 `replace_furniture`(含 .glb 拉取)| < 1500 ms | useGLTF Suspense 预加载 + KTX2 纹理 + meshopt | +| 100 次操作总耗时(无网络)| ≤ 50 ms(M1 Mac,Chrome 120) | 串行 await,但每 op 实际同步部分 < 0.5 ms | +| 内存占用(100 次替换后)| 增量 < 200 MB | LRU 缓存(§11.2)+ 卸载隐藏节点的纹理 | + +### 9.4 父几何不可变契约 + +| 规则 | 说明 | +|------|------| +| **N-9-1** `useGLTF(parentGlbUri).scene` 视为只读 | 进入编辑器立即 `scene.clone(true)` 一份给 store | +| **N-9-2** 退出编辑器不调用 `dispose` | drei 缓存父 .glb;其它页面(详情页)也用 | +| **N-9-3** 取消 Remix = 丢弃 clone 副本 + 重置 store | 父 ref 完整不变 | +| **N-9-4** Hot reload 时父 .glb 不重拉 | 缓存键 = `parent_glb_uri` | + +--- + +## 10. 自动保存与并发安全 + +### 10.1 IndexedDB 草稿 + +库选用 [`idb`](https://github.com/jakearchibald/idb)(5 KB,Promise 化)。Schema: + +```typescript +// lib/editor/draft-store.ts +import { openDB, DBSchema } from "idb"; + +interface CrowdRoomDB extends DBSchema { + drafts: { + key: string; // remix_id + value: { + remix_id: string; + overlay_json: string; // 序列化的 RemixOverlay + updated_at: number; // ms epoch + synced_at: number | null; // 上次成功 PATCH 时间;null = 从未同步 + parent_room_id: string; + parent_version_id: string; + }; + indexes: { "by-synced": number; "by-updated": number }; + }; +} + +export const dbPromise = openDB("crowdroom-editor", 1, { + upgrade(db) { + const store = db.createObjectStore("drafts", { keyPath: "remix_id" }); + store.createIndex("by-synced", "synced_at"); + store.createIndex("by-updated", "updated_at"); + }, +}); +``` + +### 10.2 防抖自动保存 + +```typescript +// stores/editor-store.ts (snippet) +import debounce from "lodash.debounce"; + +const saveDraft = debounce(async (state: EditorState) => { + const db = await dbPromise; + await db.put("drafts", { + remix_id: state.remixId, + overlay_json: JSON.stringify(state.toOverlay()), + updated_at: Date.now(), + synced_at: state.synced_at, + parent_room_id: state.parentRoomId, + parent_version_id: state.parentVersionId, + }); + // 网络可用 → PATCH /functions/v1/remix-update + if (navigator.onLine) { + const r = await fetch(`/functions/v1/remix-update`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${state.jwt}` }, + body: JSON.stringify({ remix_id: state.remixId, overlay: state.toOverlay() }), + }); + if (r.ok) await db.put("drafts", { ...(await db.get("drafts", state.remixId))!, synced_at: Date.now() }); + } +}, 3000); +``` + +> 🔄 **建议增量端点**:[`02_api_contract.md`](02_api_contract.md) §2.1 目前仅有 `E-11 remix-create`,未显式列 `remix-update`。本手册假设落地 **E-20 `PATCH /functions/v1/remix-update`**,入参 `{ remix_id, overlay }`,出参 `{ saved_at, etag }`;走 RLS owner-only。需在下一版 02 文档追认。 + +### 10.3 离线编辑 + +| 场景 | 行为 | +|------|------| +| `navigator.onLine === false` | 仍写 IndexedDB;不调 PATCH;UI 顶部黄色 toast "已离线,本地草稿已保存" | +| 恢复在线(`window.addEventListener('online', ...)`) | 立即把 `synced_at < updated_at` 的草稿 PATCH 上传,串行处理 | +| 上传失败 | 指数退避(3s / 12s / 48s),3 次后红 toast | +| 关闭页面 | `beforeunload` 拦截:若 `synced_at < updated_at`,弹"有未保存草稿,是否离开?" | + +### 10.4 多 Tab 并发(BroadcastChannel) + +```typescript +const channel = new BroadcastChannel(`remix-${remixId}`); +channel.postMessage({ type: "claim", tab_id: myTabId }); +channel.onmessage = (e) => { + if (e.data.type === "claim" && e.data.tab_id !== myTabId) { + // 已在另一标签页打开 + showDialog({ + title: "该 Remix 已在另一个标签页编辑", + body: "继续在此标签页编辑会覆盖另一处的未保存改动。", + actions: ["接管编辑", "切换到那个标签页"], + }); + } +}; +``` + +### 10.5 父版本删除的兜底 + +呼应 [`02_api_contract.md`](02_api_contract.md) §7.1 错误码 `REMIX_PARENT_DELETED` 与 [`10_governance.md`](10_governance.md) §4 P-W-3 快照转移机制: + +```typescript +async function patchRemix(state: EditorState) { + const r = await fetch(...); + if (r.status === 410) { + const { error } = await r.json(); + if (error.code === "REMIX_PARENT_DELETED") { + showDialog({ + title: "父房间已被作者删除", + body: "你的修改可保存为独立副本(平台会自动保留父几何快照)。", + actions: [ + { label: "保存为独立副本", onClick: () => promoteToStandalone(state) }, + { label: "丢弃改动", onClick: () => discardDraft(state) }, + ], + }); + } + } +} +``` + +`promoteToStandalone` 调用 E-17 `room-delete-with-snapshot` 已经准备好的 `parent_snapshot_path`([`01_data_schema.md`](01_data_schema.md) §3.6 v0.2 字段),把 Remix 的 `parent_glb_uri` 切换到 `remix-fallbacks/{room_id}/v{n}/canonical.glb`。 + +### 10.6 乐观锁(防多设备覆盖) + +`remixes` 行加 `updated_at TIMESTAMPTZ` + `etag UUID`(建议增量;当前 v0.2 schema 已有 `updated_at`,etag 可由 trigger 自动维护);PATCH 入参带 `If-Match: {etag}`,冲突时 412 + 业务码 `REMIX_STALE_VERSION`,让客户端弹"另一设备已编辑"对话框。 + +--- + +## 11. 性能与移动端优化 + +### 11.1 资产懒加载 + +| 策略 | 实现 | +|------|------| +| AssetPicker 列表用 128×128 webp 预览图 | `assets.thumbnail_path` 已存在;CDN URL 走 Next/Image | +| 点击卡片才 `useGLTF.preload(asset.glb_uri)` | 拉取与对齐计算并行 | +| 鼠标悬停 600 ms 预拉 | 防"快速划过"过度抓取 | +| KTX2 + Meshopt 纹理/几何压缩 | drei `useGLTF` 自动支持,加载体积降 50–80% | + +### 11.2 LRU 缓存 + +```typescript +import { LRUCache } from "lru-cache"; +const assetCache = new LRUCache({ max: 10, dispose: (gltf) => { + gltf.scene.traverse((o: any) => { + if (o.geometry) o.geometry.dispose(); + if (o.material?.dispose) o.material.dispose(); + }); +}}); +``` + +容量 10:典型用户一次会话替换 ≤ 30 个家具,命中率 70%+;超额淘汰时主动 dispose 几何与纹理,回收 GPU 内存。 + +### 11.3 移动端 Safari 优化 + +| 项 | 措施 | +|----|------| +| `` hitbox | `scale={1.5}` 放大 gizmo handle | +| iOS haptic | 按住 gizmo 时 `navigator.vibrate(10)`(仅 iOS 16+,Safari 部分支持) | +| Touch raycaster 容差 | hit 范围用 `Raycaster.params.Line.threshold = 0.05`(默认 1 太大) | +| dpr 锁 | `` 防 Retina 屏 GPU 过载 | + +### 11.4 大场景"图层独显"模式 + +```typescript +// 进入"专注编辑某家具层"模式 +setLayerSoloMode(true); +// -> 其它 3 层 group.visible = false +// -> 仅渲染当前编辑层 + 已 overlay 的新资产 +// -> draw call 减少 ~50%(典型房间 30 draw call -> 15) +``` + +UI:图层面板每行右上角增加 "🎯 Solo" 按钮;solo 状态下顶部 banner 显示 "正在独显 Furniture 层 [退出]"。 + +### 11.5 性能预算(M1 Mac, Chrome 120) + +| 指标 | 目标 | 关联 | +|------|------|------| +| 进入编辑器 LCP | < 3 s(含父 .glb 5 MB + manifest) | §11.1 | +| 单次替换交互响应(点击 → 渲染完成)| < 3 s | §13 M1-3 | +| 100 次操作连续替换内存增量 | < 200 MB | §11.2 LRU | +| Idle 期帧率 | ≥ 60 fps(桌面)/ ≥ 30 fps(iOS Safari)| [`04_web_app_plan.md`](04_web_app_plan.md) §3.3 | + +--- + +## 12. 失败模式与错误恢复 + +10 种失败情形 + 用户可见行为 + 内部错误码 + 自动恢复策略: + +| # | 失败情形 | 用户可见行为 | 内部错误码 | 自动恢复 | +|---|---------|------------|-----------|----------| +| F-1 | 资产 .glb 加载超时(> 10s) | 占位灰色 box + 红色"重试"按钮 | `ASSET_GLB_TIMEOUT`(前端) | 自动 1 次重试;仍失败标记该 asset 为本会话不可用 | +| F-2 | 资产元数据缺 `anchor_local` | 顶部黄色 toast "该资产未标注锚点,建议手动微调";自动进入 §6 gizmo 模式 | `ASSET_META_INCOMPLETE`(前端) | 强制 gizmo 显示;不阻断使用 | +| F-3 | 自动保存网络失败 | 红色 toast "云端同步失败,本地已保存";状态栏图标变红 | `REMIX_PATCH_FAILED`(HTTP 5xx) | 指数退避 3s/12s/48s;离线队列 | +| F-4 | overlay 校验失败(ops 引用了不存在的 item_id) | Toast "本次操作未通过校验:{detail}";自动 undo 1 步 | `OVERLAY_INVALID`([`02_api_contract.md`](02_api_contract.md) §7.1) | 自动 undo + Sentry breadcrumb | +| F-5 | asset 已下架(`ASSET_NOT_FOUND`) | AssetPicker 中该卡片置灰 "(已下架)",已选中则 toast 提醒更换 | `ASSET_NOT_FOUND`([`02_api_contract.md`](02_api_contract.md) §7.1) | 移出收藏夹;保留 overlay 中的引用直到用户主动替换 | +| F-6 | 父房间被作者硬删 | 弹窗"父房间已删除,是否保存为独立副本?" | `REMIX_PARENT_DELETED`(HTTP 410) | §10.5 `promoteToStandalone` | +| F-7 | overlay 大小 > 50 KB | 黄色 banner "改动已较多,建议精简";继续允许编辑 | `OVERLAY_SIZE_WARN`(前端) | 提示合并连续同 op;UI 列出 ops 频率统计 | +| F-8 | overlay 大小 > 1 MB | 拒绝 PATCH;红色 banner "改动超过限制,请精简后再保存" | `OVERLAY_TOO_LARGE`(HTTP 413) | 自动卷起最近 N 个 op 让用户选删 | +| F-9 | 并发同步冲突(多设备) | 弹窗"另一设备已编辑此 Remix,是否覆盖?" | `REMIX_STALE_VERSION`(HTTP 412,建议增量) | 用户选择"覆盖"重发;选择"放弃"则拉取远端 overlay 同步本地 | +| F-10 | 缩放 clamp / 朝向冲突 | UI 红 banner + 高亮 §6 gizmo;提供 "⟲ 180° 翻转" 按钮 | `ALIGN_WARNING`(前端) | 用户手动微调后 warning 清除 | + +> 所有错误都送 Sentry breadcrumb,`tags: { remix_id, op_index }`,便于事后排障;HTTP 错误码与 [`02_api_contract.md`](02_api_contract.md) §7 严格对齐。 + +--- + +## 13. M1 验收清单 + +> M1 = 物品替换能力的最小可发布版(与 [`04_web_app_plan.md`](04_web_app_plan.md) §11.1 MVP 范围对齐) + +- [ ] **A-1 选中覆盖率**:用户能点选 ≥ 90% 扫描得到的家具(按 RoomPlan `furniture_count` 抽样 30 个房间,命中 ≥ 27) +- [ ] **A-2 对齐精度**:OBB 自动对齐误差 ≤ 5 cm(中心距)+ ≤ 10°(朝向),抽样 50 个 "原家具 → 同语义新资产" 的替换实例 +- [ ] **A-3 单次替换响应**:替换 1 个沙发 < 3 s(从点击到渲染完成,含 .glb 拉取与对齐计算;M1 Mac Chrome 120) +- [ ] **A-4 内存稳定性**:100 次连续替换后 DevTools Memory 增量 < 200 MB;GC 后回到基线 ±30 MB +- [ ] **A-5 离线韧性**:IndexedDB 草稿在网络断开 5 min 后能完整恢复(关页 → 重开 → 改动仍在) +- [ ] **A-6 Schema 校验**:`remix_overlay.json` 通过本手册 §8.3 类型 + JSON Schema 校验(Edge Function 拒收非法 op) +- [ ] **A-7 跨浏览器**:iOS Safari 16+ / Chrome 120+ / Firefox 119+ 三浏览器 E2E 通过(Playwright + 视觉回归 1% 阈值) +- [ ] **A-8 父删兜底**:父房间被删后,已发布 Remix 仍可正常浏览(走 `parent_snapshot_path`,[`10_governance.md`](10_governance.md) §4 P-W-3 快照转移) +- [ ] **A-9 触屏体验**:移动端 Safari 上长按 500 ms 进入多选模式;gizmo handle 至少 32×32 CSS px +- [ ] **A-10 性能预算**:进入编辑器 LCP < 3 s(父 .glb 5 MB);Idle 帧率 60 fps(桌面) + +> 共 **10 条**,QA 在 M1 RC 阶段全部 pass 才允许打 tag。 + +--- + +## 14. 与未来 P2 能力的接口预留 + +### 14.1 多人协同编辑(CRDT) + +`remix_overlay.json` 的 `ops: OverlayOp[]` 是一个**顺序追加**结构,天然契合 CRDT。未来用 [Yjs](https://github.com/yjs/yjs) 或 [Automerge](https://github.com/automerge/automerge) 包装时,本手册建议: + +```typescript +// 未来接口(P2 不在本手册实现,仅留 hook) +interface RemixOverlayCRDT { + doc: Y.Doc; + ops: Y.Array; // 用 Y.Array 替换 plain array + presence: Y.Map; // 谁在选中哪个 item / 哪个 op +} +``` + +迁移成本:现有 `Zustand store → ops[]` 改为 `Zustand store → ops.toArray()`,订阅 `ops.observe` 即可。无需改 §8 schema。 + +### 14.2 AI 自动配色 / 风格推荐 + +预留 query 参数:`POST /functions/v1/remix-update?ai_suggest=true&suggest_kind=color|style|all`。Edge Function 在收到此参数时,**额外**调用大模型 API 返回建议 op 列表(不直接写库): + +```typescript +interface RemixUpdateResponse { + saved_at: string; + etag: string; + ai_suggestions?: OverlayOp[]; // P2 才填充;MVP 始终为 undefined +} +``` + +### 14.3 AR 即时预览 + +`viewState` 增加 `ar_mode?: boolean` 字段([`04_web_app_plan.md`](04_web_app_plan.md) §3.5 已留 `` 挂载点)。客户端检测: + +```typescript +if (viewState.ar_mode && navigator.xr) { + // 进入 @react-three/xr 的 immersive-ar session +} else if (viewState.ar_mode && /* iOS Safari */) { + // fallback 到 的 AR Quick Look +} +``` + +服务端:CDN 同时产出 `canonical_with_overlay.usdz`(合成后 USDZ;P2 才接入转码 Worker),让 iOS AR Quick Look 直接用。 + +--- + +## 15. 给子任务 10([`11_asset_library.md`](11_asset_library.md))的接口要点 + +> 本手册的算法与体验**强依赖**资产库的元数据完备性。下表是资产库**必须保证**的契约,否则本手册对应章节无法工作。 + +| # | 资产库必须保证 | 本手册依赖章节 | 失效后果 | +|---|---------------|--------------|---------| +| **AL-1** | 所有 `kind='furniture'` 资产**必须**标注 `anchor_local: [x,y,z]` + `forward_axis` + `up_axis` + `bbox_local` | §5.1.2 + §5.2 + §5.4 | 缺一项 → §5.5 兜底进手动微调,对齐精度从 ≤ 5 cm 退化到 ≤ 20 cm,M1 A-2 验收失败 | +| **AL-2** | 所有公开资产**必须**标注 `semantic_class`([`01_data_schema.md`](01_data_schema.md) §3.9 已有字段,子任务 10 需保证非空率 ≥ 99%) | §4.2 默认筛选 | semantic 缺失 → AssetPicker 无法按选中物语义过滤,所有家具混在一起,US-1 体验崩溃 | +| **AL-3** | `assets` 表新增 `tags text[]` 必须包含**风格 tag**(北欧/工业/中式/极简/复古/侘寂/包豪斯 7 类),且每件资产至少 1 个 | §4.2 style 维度 + §7 批量风格化 | 缺失 → §7 US-2 批量风格化失效 | +| **AL-4** | `assets` 表新增物化列 `volume_m3 numeric` + `dominant_color text`(hex);GIN 索引 `tags` | §4.2 `bbox_filter` / `color` 维度 | 缺失 → 检索退化到全表 scan,> 200 件时延迟 > 1 s | +| **AL-5** | 资产 .glb 必须经 KTX2 + Meshopt 压缩,单件 ≤ 1 MB(家具) / ≤ 200 KB(材质贴图集) | §11.1 性能 | 超标 → §13 A-3 单次替换 < 3 s 失败 | +| **AL-6** | 资产缩略图必须有 128×128 webp(CDN URL),生成 pipeline 与转码 Worker 一致 | §4.5 卡片 / §11.1 懒加载 | 缺失 → AssetPicker 列表加载主图 5 MB 网络成本爆炸 | +| **AL-7** | 资产 license 必须二选一:`'CC0' | 'CC-BY'`;MVP 默认仅展示 CC0 | §4.2 license 维度 + [`04_web_app_plan.md`](04_web_app_plan.md) §5.4 R-Web-5 | 引入非 CC 许可 → 版权风险 | +| **AL-8** | "材质"类资产的 `pbr` jsonb 必须含 `base_color_tex / normal_tex / roughness / metallic`([`01_data_schema.md`](01_data_schema.md) §3.9 已规约) | §8.3 `OpReplaceMaterial.pbr_override` | 字段不全 → 材质替换 fallback 到纯色,视觉退化 | +| **AL-9** | 资产 ID 与 glb_path 之间 **不可重定向**(资产一旦发布即不可改 path) | §8.2 overlay 持久化 `asset_glb_uri` | 改动 path → 已发布 Remix 加载失败 → F-5 错误码触发率飙升 | +| **AL-10** | 资产库提供 `GET /rest/v1/assets?bbox_filter=lo,hi&style=...&semantic=...` 复合筛选(本手册建议增量,§4.2 R-3-4) | §4.2 即时联动筛选 | 无此参数 → AssetPicker 拉全集后端筛 → 移动端 OOM | + +> **行动**:以上 10 条契约请子任务 10 在 [`11_asset_library.md`](11_asset_library.md) 中以"硬契约"形式承诺;本手册 §13 M1 验收 A-1 / A-2 / A-3 都是验证这 10 条的间接指标。 + +--- + +## 16. 本章小结 + +| 关键产出 | 一句话 | +|----------|--------| +| **27 步数据流序列图(§2)** | 从 raycast 命中到发布 Remix 全链路,每步映射本手册章节 | +| **5 个核心代码骨架** | useSelectableMesh §3 / AssetCard §4 / alignAssetToOBB §5 / RemixOverlay TS 类型 §8 / applyOverlay §9 | +| **3 种 OBB 对齐缩放模式** | fit-volume / fit-floor / none,按 semantic_class 智能默认 | +| **5 种 overlay op**(沿用 v0.2 既有命名) | replace_furniture / replace_material / hide_layer / set_wall_color / add_decoration | +| **零服务端预合成** | 严格遵守 Web-Y2 契约,浏览器内 clone 父 scene 后顺序应用 | +| **离线 + 多 Tab 安全** | IndexedDB 草稿 + BroadcastChannel + 父删 promoteToStandalone | +| **10 条 M1 验收** | 覆盖选中率 / 对齐精度 / 响应 / 内存 / 离线 / 跨浏览器 / 父删 / 触屏 / 性能 | +| **10 条移交给子任务 10 的硬契约** | 缺一即本手册 §5 / §11 / §13 失效 | + +读完本手册你应能: +- ✅ 直接开始写 `useSelectableMesh / alignAssetToOBB / applyOverlay` 等核心模块 +- ✅ 不被任务描述与 v0.2 实际字段命名差异困扰(§8.5 映射表已收口) +- ✅ 知道哪些"建议增量"需要在下一版 02 / 11 文档中追认 +- ✅ 接手 QA 时知道 M1 要测的 10 条具体指标 + +--- + +**章节版本**:v0.2 · 草案(子任务 9) +**关键收获**:物品替换不是单一功能,是 **raycaster + AssetPicker + OBB 对齐 + 4DoF gizmo + overlay schema + 浏览器实时合成 + 离线草稿** 七件套的合奏;任何一件套退化都让用户体验从"灵感生成器"退化到"3D 玩具"。 \ No newline at end of file diff --git a/plans/CrowdRoom/09_privacy.md b/plans/CrowdRoom/09_privacy.md new file mode 100644 index 0000000..009ffd5 --- /dev/null +++ b/plans/CrowdRoom/09_privacy.md @@ -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 Views(tooltip 文案待补)+ §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 breadcrumb:viewState 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+0–T+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` 过滤 PII;breadcrumb 中 viewState 替换为 `[REDACTED]`(§5 P-W-7) | +| **PostHog Cloud**(事件分析) | 用户事件 + funnel + feature flag 评估 | 是(PostHog EU / US 双区可选) | ✅ 选 EU 区即数据不离欧 | ✅ MVP 即签 | 强制 opt-in(C-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**」最小集即可上线。 \ No newline at end of file diff --git a/plans/CrowdRoom/10_governance.md b/plans/CrowdRoom/10_governance.md new file mode 100644 index 0000000..28c1468 --- /dev/null +++ b/plans/CrowdRoom/10_governance.md @@ -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 1–2 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 均为 3–5),低于 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 GB,Supabase 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 | P1(DAU > 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 人 + DPO;P2 引入外部委员 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 天退场承诺,把一个社区平台的「长期信用」三件套(**透明 / 公平 / 不可消失**)一次性敲定。 \ No newline at end of file diff --git a/plans/CrowdRoom/11_asset_library.md b/plans/CrowdRoom/11_asset_library.md new file mode 100644 index 0000000..b459ce5 --- /dev/null +++ b/plans/CrowdRoom/11_asset_library.md @@ -0,0 +1,1238 @@ +# CrowdRoom · 资产库(Asset Library)模块设计(v0.2) + +> **版本**:v0.2(2026-05-19)· 子任务 10 产出 +> **定位**:把 [`01_data_schema.md`](01_data_schema.md) §3.9 中**仅作为"平台 CC0 公共素材池"**存在的 `public.assets` 表,**升级**为一个支持「平台爬取导入 + 用户 UGC 上传 + Remix 衍生」三源流的资产库子系统;同时**逐条兑现** [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §15 移交的 **AL-1 ~ AL-10** 硬契约。 +> +> **本文档不修改任何 v0.2 既有架构**——所有对 [`01_data_schema.md`](01_data_schema.md) / [`02_api_contract.md`](02_api_contract.md) / [`04_web_app_plan.md`](04_web_app_plan.md) / [`10_governance.md`](10_governance.md) 的扩展统一以「**建议增量**」标注;落地需在下一版(v0.3)对应文档中追认。 +> +> 文档语言:简体中文;DDL / TS / JSON 字段命名:英文(与既有契约对齐)。 + +--- + +## 0. 阅读导航 + +| 你的角色 | 重点章节 | +|---------|---------| +| 后端 / DBA | §2 增量 ALTER · §6 端点表 | +| Edge Function / Worker | §4 平台导入管线 · §5.2 UGC 预处理 worker · §5.3 自动初筛 | +| Web 前端(AssetPicker) | §3 Storage 路径 · §6 E-A3 检索 · §7 排序公式 | +| Web 前端(上传向导) | §5.1 5 步向导 · §6 E-A1/E-A2 | +| 运营 / 审核员 | §5.3 审核工作台 · §8 创作者信任分 · §9 协议 | +| 法务 | §9 版权 · §10 收藏与署名 | +| QA | §14 M1 验收 · §15 风险 | +| Reviewer(一眼对账) | §2.6 AL 映射表 · §13 关系矩阵 · §14.2 AL 对照表 | + +--- + +## 1. 资产库定位与产品形态 + +### 1.1 一句话定义 + +> **"CrowdRoom 资产库 = 平台 CC0 公共资产(Quaternius / Poly Haven / ambientCG 等爬取/导入)+ 用户上传自制资产(UGC,CC0 或 CC-BY 二选一)+ 资产 Remix 衍生(用户基于已有资产二次创作)。"** + +它对应 [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §4 AssetPicker 的**唯一上游数据源**——AssetPicker 拉的所有家具/材质卡片均来自本资产库;同时把 [`10_governance.md`](10_governance.md) §4 P-W-4 决策的「平台资产 CC0 白名单」从一张孤立的内部素材表,扩展为一个有用户参与、有创作者飞轮的子社区。 + +### 1.2 三类资产对照 + +| # | 类型 | 来源 | 协议(MVP) | 准入流程 | 默认 `trust_score` | AssetPicker 排序权重 | +|---|------|------|-----------|---------|-------------------|---------------------| +| **T1 平台 CC0** | `source_type='platform'` | 运营批量爬取/导入(Quaternius、Poly Haven、ambientCG、Sketchfab CC0 子集) | 一律 CC0 | 自动入库 `review_status='approved'` | **80** | 高(默认头部) | +| **T2 用户上传** | `source_type='user_upload'` | 普通用户走 §5 上传向导 | CC0(MVP 强制);CC-BY 见 v0.5 ROADMAP | 自动初筛 + 人工 24-72 h 审 | **30**(新手)/ **50**(Trusted Creator 跳过人工) | 中(按 §7 综合公式) | +| **T3 Remix 衍生** | `source_type='remix_of'` + `parent_asset_id` | 用户 fork 已有资产 → 改 PBR / mesh → 发布 | 继承父协议(CC0 → 可选;CC-BY → 必须 CC-BY) | 同 T2 | 继承父 × 0.8 | 中低(避免刷量) | + +> **MVP 一致性声明**(与 [`10_governance.md`](10_governance.md) §4 P-W-4 对齐): +> +> - MVP 阶段 T1 全 CC0;T2 / T3 用户上传**强制 CC0**([`04_web_app_plan.md`](04_web_app_plan.md) §5.4 顶部「仅显示 CC0」开关**始终为 ON 且不可关**,等同于"全库即 CC0") +> - v0.5(DAU > 5 000)后才开放 CC-BY 4.0 上传选项;CC-BY-SA / CC-BY-NC / 付费素材**永不接**(避免 Remix 树协议污染) +> - 本资产库**不引入 CrowdRoom 平台默认的 CC BY-NC 协议**——资产是给所有 Remix 重用的"乐高积木",必须 NC-free + +### 1.3 与 v0.2 既有设计的边界 + +| 边界 | v0.2 既有 | 本文档扩展 | +|------|-----------|----------| +| `public.assets` 表行数 | 1 个 Storage 文件 + 9 列字段 | 仍是 1 张表,仅 **ALTER 追加 13 列**(§2) | +| 资产上传能力 | 无(仅运营 service_role 写)| 新增 8 个 E-A1~E-A8 端点(§6) | +| AssetPicker 检索维度 | `kind / semantic_class / tags` | 追加 `style_tag / volume_m3 / dominant_color / source_type / license` | +| 治理流程 | 仅"内部抽查"([`10_governance.md`](10_governance.md) §4 P-W-4 步骤 3) | 复用 [`10_governance.md`](10_governance.md) §2.1 既有 NSFW + 敏感词自动审核流,新增「资产审核队列」分支 | + +--- + +## 2. 数据模型增强(AL-1 / AL-2 / AL-4 / AL-8 落地) + +### 2.1 设计原则 + +1. **不破坏 v0.2** — [`01_data_schema.md`](01_data_schema.md) §3.9 既有 9 列字段(`id / kind / name / semantic_class / glb_path / pbr / thumbnail_path / license / source_url / tags / created_at`)**保持不动**;本节仅做 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` 增量 +2. **AL 契约逐字段落地** — 每一列都对应 AL-1 ~ AL-10 中的某一条(见 §2.6 映射表) +3. **三张支撑表** — `asset_review_queue`(审核工单)、`asset_reports`(举报)、`asset_collections`(用户收藏/官方风格集)拆为独立表,避免 `assets` 表膨胀 + +### 2.2 `assets` 表增量 ALTER + +```sql +-- 建议增量:可在 Supabase SQL Editor 中按顺序执行 +-- 与 01_data_schema.md §3.9 既有字段完全兼容,不修改既有列 + +ALTER TABLE public.assets + -- ============= AL-1: 朝向 & 包围盒(家具替换对齐契约) ============= + ADD COLUMN IF NOT EXISTS anchor_local jsonb NOT NULL DEFAULT '{}'::jsonb, + -- 结构:{"position":[x,y,z],"forward_axis":"+Z","up_axis":"+Y"} + -- position 单位米;forward_axis / up_axis ∈ {+X,-X,+Y,-Y,+Z,-Z} + ADD COLUMN IF NOT EXISTS bbox_local jsonb NOT NULL DEFAULT '{}'::jsonb, + -- {"min":[x,y,z],"max":[x,y,z]} 单位米;坐标系右手 +Y 向上(与 glTF 一致) + + -- ============= AL-4: 体量 & 主色(检索维度) ============= + ADD COLUMN IF NOT EXISTS volume_m3 numeric(10,3) NOT NULL DEFAULT 0, + -- 由 bbox_local 计算的物化列(worker 写入) + ADD COLUMN IF NOT EXISTS dominant_color text, + -- '#RRGGBB'(LAB 平均后量化);material 类资产用 base_color + -- furniture 类资产用 thumb_512 的中心 256×256 区域 K-Means(K=3) 最大簇 + + -- ============= AL-3: 风格标签 ============= + ADD COLUMN IF NOT EXISTS style_tag text, + -- enum: 'nordic' | 'industrial' | 'chinese' | 'minimal' + -- | 'retro' | 'wabi_sabi' | 'bauhaus' + -- 1 件资产仅 1 个主风格;多风格通过 tags[] 兼顾 + + -- ============= AL-7: 协议(v0.2 已有 license,此处扩展约束位置) ============= + -- license 列已存在;仅追加 CHECK 约束(见 §2.3) + + -- ============= 三源流溯源 ============= + ADD COLUMN IF NOT EXISTS source_type text NOT NULL DEFAULT 'platform', + -- enum: 'platform' | 'user_upload' | 'remix_of' + ADD COLUMN IF NOT EXISTS parent_asset_id uuid REFERENCES public.assets(id), + -- 仅 source_type='remix_of' 时非空 + ADD COLUMN IF NOT EXISTS uploader_id uuid REFERENCES public.users(id), + -- 平台资产为 NULL;user_upload / remix_of 必填 + + -- ============= 审核状态机 ============= + ADD COLUMN IF NOT EXISTS reviewer_id uuid REFERENCES public.users(id), + ADD COLUMN IF NOT EXISTS review_status text NOT NULL DEFAULT 'approved', + -- enum: 'pending' | 'approved' | 'rejected' | 'withdrawn' + -- 平台 import 默认 'approved';用户上传默认 'pending' + ADD COLUMN IF NOT EXISTS rejection_reason text, + -- 仅 review_status='rejected' 时非空;通过站内信通知作者 + + -- ============= 信任分 & 使用统计 ============= + ADD COLUMN IF NOT EXISTS trust_score smallint NOT NULL DEFAULT 50 + CHECK (trust_score BETWEEN 0 AND 100), + -- 影响 §7 AssetPicker 排序权重;详见 §8 + ADD COLUMN IF NOT EXISTS download_count int NOT NULL DEFAULT 0, + -- E-A3 详情命中 +1 + ADD COLUMN IF NOT EXISTS use_count int NOT NULL DEFAULT 0, + -- 被 remix_overlay 引用次数(trigger 自增,见 §2.5) + + -- ============= 标签(已存在 tags,此处补充注释) ============= + -- tags text[]:v0.2 既有;本文档要求 worker 自动从 CLIP/缩略图提取 ≥ 3 条 + -- 例如 ["wood","mid_century","oak","two_seater"] + + -- ============= 软删(与 v0.2 G-9 对齐) ============= + ADD COLUMN IF NOT EXISTS deleted_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); +``` + +### 2.3 license 列 CHECK 约束(AL-7 落地) + +```sql +-- 建议增量:把 v0.2 既有 `license text not null default 'CC0'` 收紧为白名单 +ALTER TABLE public.assets + ADD CONSTRAINT assets_license_whitelist + CHECK (license IN ('CC0', 'CC-BY-4.0')); + -- 'CC-BY-SA' / 'CC-BY-NC' / 'commercial' 永不允许(与 P-W-4 对齐) +``` + +### 2.4 索引(AL-4 / AL-10 落地:复合检索 < 200 ms) + +```sql +-- tags 数组 GIN(AL-4 强制要求;v0.2 §3.9 已存在 assets_tags 索引,此处幂等重建) +CREATE INDEX IF NOT EXISTS assets_tags_gin + ON public.assets USING gin (tags); + +-- 单维 btree +CREATE INDEX IF NOT EXISTS assets_semantic_class_btree + ON public.assets (semantic_class); +CREATE INDEX IF NOT EXISTS assets_volume_btree + ON public.assets (volume_m3); +CREATE INDEX IF NOT EXISTS assets_style_btree + ON public.assets (style_tag); + +-- 复合:source_type × review_status(AssetPicker 默认仅拉 approved) +CREATE INDEX IF NOT EXISTS assets_source_review_btree + ON public.assets (source_type, review_status) + WHERE deleted_at IS NULL; + +-- 信任分排序(部分索引仅覆盖 approved) +CREATE INDEX IF NOT EXISTS assets_trust_desc + ON public.assets (trust_score DESC, use_count DESC) + WHERE review_status = 'approved' AND deleted_at IS NULL; + +-- name 模糊搜索(与 rooms 一致使用 pg_trgm) +CREATE INDEX IF NOT EXISTS assets_name_trgm + ON public.assets USING gin (name gin_trgm_ops); +``` + +### 2.5 RLS 补丁(覆盖既有 §3.9 policy) + +```sql +-- v0.2 §3.9 只有 assets_select_all 一条 policy;本文档扩展为 4 条 +ALTER TABLE public.assets ENABLE ROW LEVEL SECURITY; + +-- SELECT:所有人可见 approved 的;owner 可见自己的 pending/rejected/withdrawn +DROP POLICY IF EXISTS assets_select_all ON public.assets; +CREATE POLICY assets_select_public ON public.assets FOR SELECT USING ( + deleted_at IS NULL + AND ( + review_status = 'approved' + OR uploader_id = auth.uid() + ) +); + +-- INSERT:登录用户可插入 pending(uploader_id 必须 = auth.uid()) +CREATE POLICY assets_insert_user ON public.assets FOR INSERT WITH CHECK ( + uploader_id = auth.uid() + AND source_type IN ('user_upload', 'remix_of') + AND review_status = 'pending' +); + +-- UPDATE:仅 owner 在 pending 期可改元数据;approved 后只读(service_role 走 §5.3 审核工具走 RPC) +CREATE POLICY assets_update_owner_pending ON public.assets FOR UPDATE USING ( + uploader_id = auth.uid() AND review_status = 'pending' +) WITH CHECK ( + uploader_id = auth.uid() AND review_status = 'pending' +); + +-- DELETE:owner 可在 pending 时撤回(软删);approved 后走 E-A8 走 service_role +CREATE POLICY assets_delete_owner_pending ON public.assets FOR DELETE USING ( + uploader_id = auth.uid() AND review_status = 'pending' +); + +-- use_count 自增触发器(被 remix_overlay 引用时 +1) +CREATE FUNCTION bump_asset_use_count() RETURNS trigger AS $$ +DECLARE + op_record jsonb; + ref_asset_id uuid; +BEGIN + -- 扫描 new.overlay->ops[] 中 asset_id 字段 + FOR op_record IN SELECT * FROM jsonb_array_elements(NEW.overlay->'ops') + LOOP + ref_asset_id := (op_record->>'asset_id')::uuid; + IF ref_asset_id IS NOT NULL THEN + UPDATE public.assets + SET use_count = use_count + 1, updated_at = now() + WHERE id = ref_asset_id; + END IF; + END LOOP; + RETURN NEW; +END $$ LANGUAGE plpgsql; + +CREATE TRIGGER remixes_bump_assets_use + AFTER INSERT ON public.remixes + FOR EACH ROW EXECUTE FUNCTION bump_asset_use_count(); +``` + +### 2.6 AL-1 ~ AL-10 字段映射表(**对账核心**) + +| 契约 ID | AL 要求一句话 | 落地字段 / 索引 | 文档章节 | +|---------|--------------|----------------|---------| +| **AL-1** | 家具资产必须有 anchor + forward/up + bbox | `anchor_local jsonb`、`bbox_local jsonb`(§2.2) | §2.2 / §4 / §5.2 | +| **AL-2** | 公开资产 `semantic_class` 非空率 ≥ 99% | `semantic_class`(v0.2 既有)+ `assets_semantic_class_btree`;§5.1.3 上传向导强制选 + §4 worker import 时 fallback `unknown` 标 reject | §2.4 / §5.1 | +| **AL-3** | `tags[]` 必含风格 tag(7 类),每件至少 1 个 | `style_tag text`(§2.2 enum 7 类)+ `assets_style_btree`;worker 自动从 CLIP 推断 + 上传向导默认必填 | §2.2 / §4 / §5.1 | +| **AL-4** | 物化 `volume_m3` + `dominant_color`;GIN tags | `volume_m3 numeric` + `dominant_color text` + `assets_tags_gin` + `assets_volume_btree`(§2.2 / §2.4) | §2.2 / §6 E-A3 | +| **AL-5** | .glb ≤ 1 MB(家具)/ ≤ 200 KB(材质贴图集) | §3 Storage 路径硬约束 + §5.2 worker 步骤 W-5 拒收超标 | §3 / §5.2 | +| **AL-6** | 必须 128×128 webp 缩略图 | `thumb_128.webp`(§3 路径) + §5.2 worker 步骤 W-9 自动生成 | §3 / §5.2 | +| **AL-7** | `license` 二选一 `CC0 | CC-BY` | `license` CHECK 白名单(§2.3)+ §9 协议管理 | §2.3 / §9 | +| **AL-8** | 材质 `pbr` jsonb 必含 base_color_tex/normal_tex/roughness/metallic | `pbr jsonb`(v0.2 既有)+ §5.2 worker 步骤 W-7 schema 校验 | §5.2 W-7 | +| **AL-9** | asset_id ↔ glb_path 不可重定向 | §3 路径硬规则 `public/assets/{asset_id}/model.glb`;asset_id 永不复用、glb_path 不允许 UPDATE(policy 限制) | §3 | +| **AL-10** | `GET /assets?bbox_filter=&style=&semantic=` 复合检索 | E-A3 端点(§6) + §2.4 复合索引 | §6 / §7 | + +> **结论**:AL-1 ~ AL-10 **每一条都对应到至少一个具体字段/索引/Worker 步骤/端点**,**10/10 闭环**。 + +### 2.7 支撑表 DDL + +#### 2.7.1 `asset_review_queue`(审核工单) + +```sql +CREATE TYPE asset_review_action AS ENUM ( + 'approve', 'approve_with_edits', 'reject', 'escalate' +); + +CREATE TABLE public.asset_review_queue ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + asset_id uuid NOT NULL REFERENCES public.assets(id) ON DELETE CASCADE, + enqueued_at timestamptz NOT NULL DEFAULT now(), + priority smallint NOT NULL DEFAULT 5, + -- 1=最高(举报触发的复审)/ 5=新上传 / 9=平台 import 抽查 + auto_signals jsonb NOT NULL DEFAULT '{}'::jsonb, + -- {nsfw_score: 0.2, triangle_count: 38912, pii_detected: false, ...} + reviewer_id uuid REFERENCES public.users(id), + reviewed_at timestamptz, + action asset_review_action, + notes text, + sla_deadline timestamptz NOT NULL DEFAULT (now() + INTERVAL '72 hours') +); + +CREATE INDEX queue_pending ON public.asset_review_queue (priority, enqueued_at) + WHERE reviewed_at IS NULL; +CREATE INDEX queue_asset ON public.asset_review_queue (asset_id); + +ALTER TABLE public.asset_review_queue ENABLE ROW LEVEL SECURITY; +-- 仅有 'reviewer' role 或 service_role 可访问(service_role 绕过 RLS) +-- reviewer role 通过 auth.jwt() ->> 'role' 区分 +CREATE POLICY queue_reviewer_only ON public.asset_review_queue FOR ALL + USING (auth.jwt() ->> 'role' = 'reviewer'); +``` + +#### 2.7.2 `asset_reports`(侵权 / 违规举报) + +```sql +CREATE TABLE public.asset_reports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + asset_id uuid NOT NULL REFERENCES public.assets(id) ON DELETE CASCADE, + reporter_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + reason text NOT NULL + CHECK (reason IN ('copyright', 'nsfw', 'low_quality', 'duplicate', + 'wrong_metadata', 'malicious', 'other')), + detail text, + created_at timestamptz NOT NULL DEFAULT now(), + resolved_at timestamptz, + resolution text + CHECK (resolution IN ('confirmed_violation', 'false_report', 'duplicate_report')), + UNIQUE (asset_id, reporter_id) -- 同人对同资产 24h 内只能举报一次(业务层强制) +); + +CREATE INDEX reports_asset ON public.asset_reports (asset_id); +CREATE INDEX reports_pending ON public.asset_reports (created_at) + WHERE resolved_at IS NULL; + +ALTER TABLE public.asset_reports ENABLE ROW LEVEL SECURITY; +CREATE POLICY reports_insert_user ON public.asset_reports FOR INSERT + WITH CHECK (reporter_id = auth.uid()); +CREATE POLICY reports_select_owner ON public.asset_reports FOR SELECT + USING (reporter_id = auth.uid() OR auth.jwt() ->> 'role' = 'reviewer'); +``` + +> **联动**:累计 ≥ 3 条独立 `asset_reports` 行未 resolved 时,触发 `assets.review_status` 自动改为 `pending` + 工单进 `asset_review_queue` 重审(priority=1),与 [`10_governance.md`](10_governance.md) §2.2「3 独立举报自动隐藏」对齐。 + +#### 2.7.3 `asset_collections`(用户收藏夹 / 官方风格集) + +```sql +CREATE TABLE public.asset_collections ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 80), + description text, + is_public boolean NOT NULL DEFAULT false, + is_official boolean NOT NULL DEFAULT false, + -- true = 审核员打包的「官方风格集」,发现页可展示 + asset_ids uuid[] NOT NULL DEFAULT ARRAY[]::uuid[], + cover_color text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX collections_owner ON public.asset_collections (owner_id); +CREATE INDEX collections_official ON public.asset_collections (is_official) + WHERE is_official = true; + +ALTER TABLE public.asset_collections ENABLE ROW LEVEL SECURITY; +CREATE POLICY collections_select ON public.asset_collections FOR SELECT + USING (is_public OR is_official OR owner_id = auth.uid()); +CREATE POLICY collections_write_owner ON public.asset_collections FOR ALL + USING (owner_id = auth.uid()) WITH CHECK (owner_id = auth.uid()); +-- is_official 仅 service_role 可写 +``` + +#### 2.7.4 `creator_profiles`(创作者信任分扩展) + +```sql +-- 复用 public.users,新增创作者维度独立列 +ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS creator_trust_score smallint NOT NULL DEFAULT 50 + CHECK (creator_trust_score BETWEEN -100 AND 100), + ADD COLUMN IF NOT EXISTS creator_badge text + CHECK (creator_badge IN ('trusted', 'verified', 'pro')), + ADD COLUMN IF NOT EXISTS creator_uploaded_count int NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS creator_use_count_total int NOT NULL DEFAULT 0; + +CREATE INDEX users_creator_trust ON public.users (creator_trust_score DESC) + WHERE deleted_at IS NULL AND creator_uploaded_count > 0; +``` + +### 2.8 字段总览 + +| 项 | 数量 | +|----|------| +| 新增 `assets` 列 | 13 列 | +| 新增 `users` 列(创作者扩展) | 4 列 | +| 新增表 | 3 张(`asset_review_queue`、`asset_reports`、`asset_collections`) | +| 新增索引 | 6(`assets`)+ 5(支撑表)+ 1(users 创作者)= 12 个 | +| 新增 trigger | 2 个(`bump_asset_use_count` + `assets_glb_path_lock`,后者见 §3.3) | +| 新增 RLS policy | 4(`assets`)+ 7(支撑表)= 11 条 | + +--- + +## 3. Storage 目录与命名约定(AL-5 / AL-6 / AL-9 落地) + +### 3.1 公开 bucket 目录扩展 + +``` +public/assets/{asset_id}/ # asset_id = uuid v4 32 位 hex(无 -) + ├── model.glb # 主几何,KTX2 + Meshopt 压缩 + │ # 家具 ≤ 1 MB;材质贴图集 ≤ 200 KB(AL-5) + ├── thumb_128.webp # 128×128 列表缩略图(AL-6) + ├── preview_512.webp # 512×512 详情页预览 + ├── preview_3d.glb # 可选,更高 LOD 的预览版(≤ 300 KB,仅 trust>70 资产生成) + ├── attribution.txt # CC-BY 必须;CC0 可选(保留原作者署名) + └── manifest.json # 资产元信息冗余存储(与 assets 行字段对齐,便于离线检索) +``` + +> 与 [`01_data_schema.md`](01_data_schema.md) §4 的 `rooms/` / `private/rooms/` 双 bucket 模型并列:本目录复用 v0.2 既有的 `public` bucket(与 `rooms/` 同 bucket,仅顶层 prefix 不同),无需新建 bucket。 + +### 3.2 命名硬规则(AL-9 落地) + +| 规则 | 说明 | +|------|------| +| **R-1 asset_id 永不复用** | 资产被 `withdrawn` 后 `id` 不释放;新上传始终拿 `gen_random_uuid()` | +| **R-2 model.glb 路径不可重定向** | `glb_path = 'public/assets/{asset_id}/model.glb'` 写死;trigger `assets_glb_path_lock`(§3.3)拒绝任何 UPDATE 改 `glb_path`(service_role 也不放行,避免误操作) | +| **R-3 thumb_128.webp 命名固定** | 不允许 `thumb1.webp` / `cover.webp` 等变体——AssetPicker 拼路径用 | +| **R-4 .tmp 隔离** | Worker 写入时先到 `public/assets/{asset_id}/.tmp/`,转码完成 rename 到正式路径,避免半成品被读 | +| **R-5 attribution.txt UTF-8** | 多个原作者用 `\n` 分隔;格式:`"" CC-BY-4.0` | + +### 3.3 glb_path 不变性的 SQL 兜底 + +```sql +-- 建议增量:禁止任何客户端改 glb_path(包括 owner / service_role) +CREATE FUNCTION assets_glb_path_immutable() RETURNS trigger AS $$ +BEGIN + IF NEW.glb_path IS DISTINCT FROM OLD.glb_path THEN + RAISE EXCEPTION 'glb_path is immutable (AL-9); asset_id=%', OLD.id + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END $$ LANGUAGE plpgsql; + +CREATE TRIGGER assets_glb_path_lock + BEFORE UPDATE ON public.assets + FOR EACH ROW EXECUTE FUNCTION assets_glb_path_immutable(); +``` + +--- + +## 4. 平台资产导入管线(爬虫 / 外部库映射) + +### 4.1 来源清单 + +| 来源 | 协议 | 类型 | MVP 数量目标 | P1 目标 | 备注 | +|------|------|------|-------------|---------|------| +| **Quaternius**(github.com/quaternius) | CC0 | 家具 / 装饰 | 300 件 | 2 000 件 | `Furniture Kit`、`Plant Pack` 等 | +| **Poly Haven**(polyhaven.com) | CC0 | 材质 PBR | 150 套 | 800 套 | base/normal/roughness/metallic/ao 五贴图集 | +| **ambientCG** | CC0 | 材质 PBR | 100 套 | 500 套 | 同上 | +| **Sketchfab CC0 子集** | CC0 | 家具 | 50 件 | 200 件 | **每件单独人工 verify 协议**(Sketchfab 标错率高) | +| **Free3D CC0 子集** | CC0 | 家具 | 20 件 | 100 件 | P2 再放量 | + +### 4.2 导入 worker 流程图 + +```mermaid +flowchart TD + Src[Quaternius repo / Poly Haven API / ambientCG zip] --> Fetch[1 Fetch 原始文件] + Fetch --> Detect{2 格式检测} + Detect -- .glb --> Compress + Detect -- .fbx / .obj --> Convert[3 gltf-transform convert] + Convert --> Compress[4 KTX2 + Meshopt 压缩] + Compress --> Limit{5 size check} + Limit -- 超 1 MB --> RetryLOD[6a 再 decimate 到 50k tri] + Limit -- pass --> Bbox[6b 计算 bbox / volume] + RetryLOD --> Bbox + Bbox --> Color[7 提取 dominant_color K-Means] + Color --> Tags[8 CLIP 推断 tags + style_tag] + Tags --> Thumb[9 headless three.js 生成 thumb_128 / preview_512] + Thumb --> Attr[10 写 attribution.txt 即便 CC0] + Attr --> Upload[11 上传到 public/assets/uuid/] + Upload --> DB[12 INSERT assets row source_type=platform review_status=approved trust_score=80] + DB --> Done[完成] +``` + +### 4.3 导入脚本骨架(TypeScript / Node.js,从 Quaternius 批量) + +```typescript +// scripts/import_quaternius.ts +// +// 用法:pnpm tsx scripts/import_quaternius.ts --pack=furniture-kit --limit=50 +// +// 前置依赖(package.json): +// "@gltf-transform/core", "@gltf-transform/extensions", "@gltf-transform/functions", +// "@supabase/supabase-js", "sharp", "open-clip-ts" +// 环境变量:SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / QUATERNIUS_REPO_DIR + +import { NodeIO } from '@gltf-transform/core'; +import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; +import { weld, dedup, meshopt, textureCompress } from '@gltf-transform/functions'; +import { createClient } from '@supabase/supabase-js'; +import sharp from 'sharp'; +import { readdir } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; + +const SEMANTIC_FROM_FILENAME: Record = { + bed: 'bed', chair: 'chair', table: 'table', sofa: 'sofa', desk: 'table', + shelf: 'storage', dresser: 'storage', tv: 'television', +}; +const STYLE_FROM_PACK: Record = { + 'furniture-kit': 'minimal', 'midcentury': 'retro', 'modern': 'nordic', +}; + +const supa = createClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! +); +const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); + +async function importOne(srcPath: string, pack: string) { + const asset_id = randomUUID().replace(/-/g, ''); + const name = srcPath.split('/').pop()!.replace(/\.glb$/, ''); + + // 1. 加载 + 压缩 + const doc = await io.read(srcPath); + await doc.transform( + weld(), dedup(), + meshopt({ level: 'medium' }), + textureCompress({ encoder: 'ktx2', targetFormat: 'auto', quality: 90 }) + ); + const glb = await io.writeBinary(doc); + if (glb.byteLength > 1_000_000) { + console.warn(`[skip ${name}] ${glb.byteLength}B > 1 MB`); return; + } + + // 2. bbox / volume + const scene = doc.getRoot().listScenes()[0]; + let min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]; + scene.traverse((node) => { + const mesh = node.getMesh(); if (!mesh) return; + mesh.listPrimitives().forEach(p => { + const pos = p.getAttribute('POSITION'); if (!pos) return; + for (let i = 0; i < pos.getCount(); i++) { + const v = [0, 0, 0]; pos.getElement(i, v); + for (let k = 0; k < 3; k++) { + if (v[k] < min[k]) min[k] = v[k]; + if (v[k] > max[k]) max[k] = v[k]; + } + } + }); + }); + const volume_m3 = (max[0]-min[0]) * (max[1]-min[1]) * (max[2]-min[2]); + + // 3. anchor = bbox 底面中心;forward/up 默认 +Z/+Y(AL-1) + const anchor_local = { + position: [(min[0]+max[0])/2, min[1], (min[2]+max[2])/2], + forward_axis: '+Z', up_axis: '+Y', + }; + + // 4. dominant_color(用首个材质的 baseColorFactor 近似;正式版用 K-Means) + const mat = doc.getRoot().listMaterials()[0]; + const bc = mat?.getBaseColorFactor() ?? [0.6, 0.6, 0.6, 1]; + const dominant_color = '#' + bc.slice(0, 3) + .map((c: number) => Math.round(c * 255).toString(16).padStart(2, '0')).join(''); + + // 5. CLIP tags(伪代码;正式实现挂 open-clip-ts 对预设词典 zero-shot 分类) + const tags = await clipExtractTags(srcPath); + const matchedKey = Object.keys(SEMANTIC_FROM_FILENAME) + .find(k => name.toLowerCase().includes(k)); + const semantic_class = matchedKey ? SEMANTIC_FROM_FILENAME[matchedKey] : 'storage'; + const style_tag = STYLE_FROM_PACK[pack] ?? 'minimal'; + + // 6. 缩略图(假设外部已渲好同名 .png;正式版走 headless three.js) + const thumb128 = await sharp(srcPath.replace('.glb', '.png')) + .resize(128, 128).webp({ quality: 85 }).toBuffer(); + const preview512 = await sharp(srcPath.replace('.glb', '.png')) + .resize(512, 512).webp({ quality: 90 }).toBuffer(); + + // 7. attribution.txt(CC0 也保留来源 URL) + const attribution = + `Quaternius "${name}" — CC0 Public Domain\n` + + `https://github.com/quaternius/${pack}\n`; + + // 8. 上传 Storage + const root_path = `assets/${asset_id}`; + await Promise.all([ + supa.storage.from('public').upload(`${root_path}/model.glb`, glb, + { contentType: 'model/gltf-binary' }), + supa.storage.from('public').upload(`${root_path}/thumb_128.webp`, thumb128, + { contentType: 'image/webp' }), + supa.storage.from('public').upload(`${root_path}/preview_512.webp`, preview512, + { contentType: 'image/webp' }), + supa.storage.from('public').upload(`${root_path}/attribution.txt`, + Buffer.from(attribution, 'utf-8'), { contentType: 'text/plain' }), + ]); + + // 9. INSERT assets 行(service_role 绕过 RLS) + const { error } = await supa.from('assets').insert({ + id: asset_id, kind: 'furniture', name, semantic_class, + glb_path: `public/${root_path}/model.glb`, + thumbnail_path: `public/${root_path}/thumb_128.webp`, + license: 'CC0', + source_url: `https://github.com/quaternius/${pack}`, + tags, style_tag, + anchor_local, bbox_local: { min, max }, + volume_m3, dominant_color, + source_type: 'platform', review_status: 'approved', trust_score: 80, + }); + if (error) throw error; + console.log(`[ok] ${name} → ${asset_id} (${(glb.byteLength/1024).toFixed(1)} KB)`); +} + +async function clipExtractTags(p: string): Promise { + // 伪代码:真实实现挂 open-clip-ts,对 ['wood','metal','fabric','glass',...] 做多标签分类 + return ['wood', 'natural', 'mid_century']; +} + +(async () => { + const args = Object.fromEntries( + process.argv.slice(2).map(s => s.replace(/^--/, '').split('=')) + ); + const dir = join(process.env.QUATERNIUS_REPO_DIR!, args.pack); + const files = (await readdir(dir)).filter(f => f.endsWith('.glb')); + for (const f of files.slice(0, parseInt(args.limit ?? '50', 10))) { + try { await importOne(join(dir, f), args.pack); } + catch (e) { console.error(`[fail] ${f}: ${(e as Error).message}`); } + } +})(); +``` + +> **审计要点**:导入脚本以 `SUPABASE_SERVICE_ROLE_KEY` 运行,绕过 RLS;仅运营人员可在受控环境执行;每次执行写一行到运营内部审计表(建议增量;不展开 DDL)。 + +### 4.4 CC-BY 资产的差异化处理(v0.5 起开放) + +| 步骤 | CC0(MVP) | CC-BY(v0.5+) | +|------|-----------|--------------| +| `attribution.txt` | 可选 | **必须**,格式 `"" CC-BY-4.0` | +| AssetPicker 卡片 | 不显示作者 | 卡片底部小字「by {creator}」 | +| Remix 引用时 | 无追加 | overlay 自动追加 `attribution: { asset_id, creator, license }` | +| Remix 详情页 | 不显示 | 页脚「Assets in this remix」区块列出全部 CC-BY 资产 + 作者署名 | + +--- + +## 5. 用户上传 UGC 资产的完整流程(最核心章节) + +### 5.1 上传入口与 UX + +#### 5.1.1 入口路由(**建议增量** to [`04_web_app_plan.md`](04_web_app_plan.md) §1.1 路由表) + +| ID | 路由 | 描述 | +|----|------|------| +| **R-18**(建议增量) | `/me/assets` | 用户主页「我的资产」Tab(列表 + 状态徽章) | +| **R-18a**(建议增量) | `/me/assets/new` | 上传向导(5 步) | +| **R-18b**(建议增量) | `/me/assets/[asset_id]/edit` | 编辑元数据(仅 `review_status='pending'` 时可改) | +| **R-19**(建议增量) | `/assets` | 公共资产浏览页(AssetPicker 的独立网页版) | +| **R-19a**(建议增量) | `/assets/[asset_id]` | 资产详情页(含使用此资产的 Remix 列表) | +| **R-17b**(建议增量) | `/admin/assets` | 审核员资产工作台(扩展自既有 R-17 `/admin/reports`) | + +#### 5.1.2 入口可见性 + +| 位置 | CTA 文案 | 触达条件 | +|------|---------|---------| +| 发现页 `/` 底部 | 「成为创作者 — 上传你的第一件资产 →」 | 登录用户且 `creator_uploaded_count=0` | +| 用户主页 `/u/[handle]` | 顶部 Tab「我的资产」 | 仅自己可见上传按钮;他人看到的是已 approved 资产列表 | +| Remix 编辑器 AssetPicker 底部 | 「找不到合适的?上传自己的 →」按钮 | 登录用户始终可见 | +| Header 用户菜单 | 「我的资产」菜单项 | 登录用户始终可见 | + +#### 5.1.3 上传向导 5 步(`/me/assets/new`) + +```mermaid +flowchart LR + S1[Step1 选文件] --> S2[Step2 预览+确认朝向] + S2 --> S3[Step3 填元数据] + S3 --> S4[Step4 协议同意] + S4 --> S5[Step5 提交 pending] + S5 -.审核24-72h.-> Done[approved or rejected] +``` + +| 步骤 | UI 元素 | 校验 | +|------|---------|------| +| **1 选文件** | Dropzone + 文件选择器;接受 `.glb` / `.usdz` / `.gltf+bin+textures.zip` | 单文件 ≤ 10 MB(worker 端再压到 ≤ 1 MB) | +| **2 自动预览** | R3F `` 内嵌即时渲染 + OrbitControls;下方 `forward_axis` / `up_axis` 6 选 1 单选器 | 必须确认朝向,否则下一步按钮禁用 | +| **3 元数据** | name(≤ 80 字符)/ kind 单选(家具/材质)/ semantic_class 16 选 1(家具)或 6 选 1(材质)/ style_tag 7 选 1 / tags ≤ 8 个 | semantic_class、style_tag 必填(AL-2 / AL-3) | +| **4 协议** | 单选「我声明此资产为原创或合法授权」+「我同意按 CC0 发布(MVP 强制)」 | 两个 checkbox 必勾 | +| **5 提交** | 点击「提交审核」→ 调 E-A1 拿 presigned URL → PUT Storage → 调 E-A2 标 complete → 入审核队列 | — | + +### 5.2 自动化预处理 Worker + +触发自 E-A2 `POST /functions/v1/asset-upload-complete`;**复用** [`02_api_contract.md`](02_api_contract.md) §3 既有 Transcode Worker 的基础设施(同 Cloud Run / Docker / 重试策略),但走独立的 `asset-` 分支函数集。 + +#### 5.2.1 完整 18 步序列图 + +```mermaid +sequenceDiagram + autonumber + participant U as 用户 + participant Web as Web 上传向导 + participant Edge as Edge Function + participant Stg as Supabase Storage + participant DB as Postgres + participant W as Asset Worker + participant Mod as Auto Moderator + participant Q as asset_review_queue + participant R as Reviewer + + U->>Web: 完成 5 步向导 + Web->>Edge: POST asset-upload-init filename size mime + Edge->>DB: INSERT assets row review_status=pending source_type=user_upload + Edge->>Stg: 签 presigned PUT URL TTL=300s + Edge-->>Web: asset_id + presigned_url + Web->>Stg: PUT raw file 到 tmp upload bin + Web->>Edge: POST asset-upload-complete asset_id metadata + Edge->>W: enqueue process_asset + W->>Stg: GET tmp upload bin + W->>W: 格式校验 拒 obj fbx 通过 glb usdz + W->>W: 安全扫描 embedded scripts malicious buffer + W->>W: 如 usdz 转 glb usdzconvert + W->>W: KTX2 Meshopt 压缩 + W->>W: 三角面数检查 家具 50k 上限 超则 reject + W->>W: 自动计算 bbox volume anchor 默认底面中心 + W->>W: 提取 dominant_color tags CLIP + W->>W: PBR 完整性校验 材质必含 base_color normal roughness metallic AL-8 + W->>W: 生成 thumb_128 preview_512 webp + W->>Mod: NSFW NSFWJS 与 PII 人脸检测 + alt 命中 NSFW>=0.9 或 含人脸 + Mod-->>DB: UPDATE assets SET review_status=rejected rejection_reason + Mod->>U: 站内信通知 + else 通过 + W->>Stg: 上传 model glb thumb preview manifest + W->>DB: UPDATE assets 完整元数据 review_status 保持 pending + W->>Q: INSERT asset_review_queue priority=5 sla=72h + Q->>R: 推送审核任务 + end +``` + +#### 5.2.2 步骤明细表 + +| ID | 步骤 | 工具 | 失败码 | 是否阻塞 | +|----|------|------|--------|---------| +| W-1 | 格式校验 | mime + 文件头 magic bytes 检查 | `ASSET_FORMAT_UNSUPPORTED` | 阻塞 | +| W-2 | 安全扫描 | 自定义 glTF 解析:检查 `extras.script` / 异常 binary chunk 长度 | `ASSET_MALICIOUS` | 阻塞 | +| W-3 | usdz→glb | `usdzconvert` | `USDZ_DECODE_FAILED` | 阻塞 | +| W-4 | 压缩 | `gltf-transform meshopt + textureCompress(ktx2)` | `MESH_COMPRESSION_FAILED` | 阻塞 | +| W-5 | 三角面数限制 | 自研:scene.traverse 累加 indices.count/3 | `ASSET_OVER_BUDGET`(家具 > 50k tri / 材质 quad > 5k tri) | 阻塞 | +| W-6 | bbox / volume / anchor | 同 §4.3 import 脚本逻辑 | — | — | +| W-7 | PBR 校验(材质) | 检查 `pbr.base_color_tex` / `normal_tex` / `roughness` / `metallic` 全在 | `ASSET_PBR_INCOMPLETE`(AL-8) | 阻塞 | +| W-8 | dominant_color + tags | K-Means + CLIP zero-shot | `CLIP_INFERENCE_FAILED` | 可降级(用空 tags 进人工审) | +| W-9 | 缩略图 | headless three.js → sharp resize webp | `THUMBNAIL_FAILED` | 可降级(用占位图) | +| W-10 | Auto Moderator | NSFWJS + 人脸 detection(贴图扫描) | `ASSET_NSFW_BLOCKED` / `ASSET_PII_DETECTED` | **直接 rejected** | + +### 5.3 资产审核(人工 + 自动) + +#### 5.3.1 自动初筛(Mod 端) + +| 信号 | 阈值 | 动作 | +|------|------|------| +| NSFW(NSFWJS 对 `preview_512.webp`) | `score ≥ 0.9` | 直接 `review_status='rejected'` + `rejection_reason='auto_nsfw'` | +| NSFW | `0.5 ≤ score < 0.9` | 入队列 priority=2(高于普通新上传) | +| 三角面数(家具) | `> 50 000 tri` | 直接 rejected + `rejection_reason='over_triangle_budget'` | +| 三角面数(材质 quad) | `> 5 000 tri` | 同上 | +| 贴图含人脸(face detection) | 任一面孔 | rejected + `rejection_reason='pii_face_detected'` | +| 与既有资产 SHA256 重复 | 命中 | rejected + `rejection_reason='duplicate_of='` | +| 重复上传同一文件 | 同 uploader 24h 内 ≥ 3 次失败 | 触发用户级速率限制(与 §8 信任分联动) | + +#### 5.3.2 人工审核工作台(`/admin/assets`,**建议增量** to [`04_web_app_plan.md`](04_web_app_plan.md) R-17) + +布局(沿用既有 `/admin/reports` 风格): + +| 区域 | 内容 | +|------|------| +| 左侧列表 | `asset_review_queue` 按 `priority ASC, enqueued_at ASC` 排序;徽章显示 sla 剩余时间 | +| 右上预览 | R3F `` 360° 旋转预览;显示 `triangle_count / volume_m3 / bbox` 与压缩后大小 | +| 右下元数据 | uploader、semantic_class、style_tag、tags、license、`auto_signals` JSON 摘要 | +| 底部动作栏 | 3 按钮:Approve / Approve with edits / Reject(弹窗填 reason) + Escalate(升给 owner-team) | + +#### 5.3.3 三种审核动作 + +| 动作 | 后果 | trust_score 起步 | +|------|------|-----------------| +| **Approve** | `review_status='approved'`,立即可在 AssetPicker 中被搜到 | 30(默认新上传) | +| **Approve with edits** | 审核员先补全 `anchor_local` / `tags` / `style_tag` 等字段,再走 Approve | 30 | +| **Reject** | 必填 `rejection_reason`;asset 软删(`deleted_at`),文件物理保留 7 天供作者下载备份 | — | + +#### 5.3.4 SLA 与超时回退 + +- **SLA 72 h**(与 [`10_governance.md`](10_governance.md) §3.1 MVP 1 兼职 reviewer + 30-50 工单/日上限对齐) +- **超时**:72 h 未审,资产自动以 `trust_score=20` 上架(**风险**,需明确告知 reviewer 团队)+ 工单升级到 owner-team +- 与 [`10_governance.md`](10_governance.md) §2.1 既有 NSFW/敏感词流水线**复用阈值**:NSFW ≥ 0.9 硬下架,0.5-0.9 入队列 + +### 5.4 资产 Remix(用户基于已有资产二次创作) + +#### 5.4.1 触发入口 + +- 资产详情页 `/assets/[asset_id]` 右上「Fork 这个资产」按钮(MVP 仅 `kind='material'` 开放;家具 Remix 延后到 v0.5) +- Fork 后进入轻量编辑器:调 base_color / roughness / metallic 滑块;不允许改 mesh + +#### 5.4.2 数据流 + +``` +parent asset (CC0 or CC-BY) + | + v +POST /asset-fork { parent_asset_id, modifications: { pbr_override: {...} } } + | + v +INSERT new assets row: + source_type = 'remix_of' + parent_asset_id = + uploader_id = auth.uid() + review_status = 'pending' + license = 父协议(CC0 父可选 CC0/CC-BY;CC-BY 父强制 CC-BY) + pbr = 父 pbr 叠加 modifications.pbr_override + trust_score = round(parent.trust_score * 0.8) +``` + +#### 5.4.3 协议传染规则(与 §9 一致) + +| 父 license | 子可选 license | 强制行为 | +|-----------|---------------|---------| +| CC0 | CC0 或 CC-BY(v0.5+) | 子选 CC-BY 时仅署"作为 remix 作者的自己" | +| CC-BY-4.0 | **必须** CC-BY-4.0 | 子的 `attribution.txt` 必须**叠加**父的原作者署名(追加,不替换) | + +--- + +## 6. API 端点增量(AL-10 落地) + +**建议增量** to [`02_api_contract.md`](02_api_contract.md) §2.1 端点全表,新增 8 条 E-A1~E-A8,沿用既有命名风格与速率限流维度。 + +### 6.1 端点全表 + +| ID | METHOD | 路径 | 类型 | 入参 | 出参 | 鉴权 | 速率 | 业务错误码 | +|----|--------|------|------|------|------|------|------|-----------| +| **E-A1** | POST | `/functions/v1/asset-upload-init` | Edge | `{ filename, size, mime, kind }` | `{ asset_id, presigned_put_url, expires_at }` | user | **10/h/user** | `FILE_TOO_LARGE`, `ASSET_FORMAT_UNSUPPORTED`, `QUOTA_EXCEEDED`, `CREATOR_FROZEN` | +| **E-A2** | POST | `/functions/v1/asset-upload-complete` | Edge | `{ asset_id, name, semantic_class, style_tag, tags[], license, kind, anchor_local? }` | `{ asset_id, review_status: 'pending', queue_position }` | user(owner) | 10/h/user | `ASSET_NOT_FOUND`, `ASSET_SOURCE_MISSING`, `ASSET_METADATA_INVALID` | +| **E-A3** | GET | `/rest/v1/assets?...`(详见 §6.2) | PostgREST | query string | `Asset[]` | anon | 60/min/IP | `RLS_DENIED`, `SEARCH_QUERY_TOO_SHORT` | +| **E-A4** | POST | `/functions/v1/asset-review-action` | Edge | `{ asset_id, action, reason?, edits? }` | `{ ok, new_review_status }` | reviewer | 100/h/reviewer | `ASSET_NOT_FOUND`, `ASSET_ALREADY_REVIEWED`, `REVIEWER_FORBIDDEN` | +| **E-A5** | POST | `/functions/v1/asset-fork` | Edge | `{ parent_asset_id, name, pbr_override?, license }` | `{ new_asset_id, review_status: 'pending' }` | user | **5/h/user** | `ASSET_NOT_FOUND`, `ASSET_FORK_LICENSE_CONFLICT`, `ASSET_FORK_NON_REMIXABLE` | +| **E-A6** | POST | `/functions/v1/asset-report` | Edge | `{ asset_id, reason, detail? }` | `{ report_id }` | user | 5/h/user | `ASSET_NOT_FOUND`, `REPORT_DUPLICATE` | +| **E-A7** | POST | `/rest/v1/asset_collections` | PostgREST | `{ name, asset_ids[], is_public, description? }` | `AssetCollection` | user | 20/h/user | `RLS_DENIED`, `COLLECTION_NAME_TAKEN` | +| **E-A8** | DELETE | `/rest/v1/assets?id=eq.{asset_id}` | PostgREST | path | `204` | owner(pending) | 5/h/user | `RLS_DENIED`, `ASSET_ALREADY_APPROVED` | + +> 注:E-A8 仅 `review_status='pending'` 时允许 owner 走 PostgREST DELETE(由 §2.5 policy `assets_delete_owner_pending` 强制);approved 后须改走 E-A4 reviewer 路径(reviewer 可执行 `withdrawn` 动作,软删并清 Storage 文件)。 + +### 6.2 E-A3 资产检索查询参数语义(核心,AL-10 落地) + +``` +GET /rest/v1/assets + ?review_status=eq.approved # 默认仅展示 approved(前端固定) + &kind=eq.furniture # 'furniture' | 'material' + &semantic_class=eq.sofa # 16 类 / 6 类 + &style_tag=eq.nordic # 7 类 + &bbox_filter=lo,hi # 体量范围 m^3 + &color=hue:200,tol:20 # 主色相 hue ± tol(HSL 空间) + &license=in.(CC0,CC-BY-4.0) # 协议白名单 + &q=oak # name + tags 模糊(pg_trgm + GIN) + &source_type=in.(platform,user_upload) + &order=trust_score.desc,use_count.desc + &limit=20 + &offset=40 +``` + +#### 6.2.1 `bbox_filter=lo,hi` 语义 + +- 用户在 AssetPicker 选中一个家具,其原 OBB 体量 `vol_target` m³;前端拼 `lo = vol_target * 0.5`, `hi = vol_target * 1.5` +- 实现:在 RPC `search_assets` 中翻译为 `WHERE volume_m3 BETWEEN $lo AND $hi` +- 容差:默认 ±50%(含 0.5×~1.5×;过严会让候选集小于 5 件,过松失去过滤意义);与 [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §4.2 推荐的 ±20% 兼容(参数化由前端控制) +- 索引:`assets_volume_btree`(§2.4) + +#### 6.2.2 `color=hue:200,tol:20` 语义 + +- 在 HSL 色空间筛主色相 ± tol(角度 0-360) +- 实现:服务端把 `dominant_color` 的 hex → HSL,做 `WHERE ABS(hue_diff(...)) < $tol` +- 简化版(MVP):先用六色桶(warm/cold/neutral/red/blue/green)做粗筛,避免每次 SQL 调用 HSL 转换函数 +- 进阶(P2):LAB 距离 `< 阈值`,更感知一致 + +#### 6.2.3 复合查询 SQL 实现示例 + +```sql +-- 建议增量:暴露 RPC,便于复杂参数封装 +CREATE OR REPLACE FUNCTION public.search_assets( + p_kind text DEFAULT NULL, + p_semantic_class text DEFAULT NULL, + p_style_tag text DEFAULT NULL, + p_vol_lo numeric DEFAULT NULL, + p_vol_hi numeric DEFAULT NULL, + p_hue smallint DEFAULT NULL, + p_hue_tol smallint DEFAULT 20, + p_q text DEFAULT NULL, + p_limit int DEFAULT 20, + p_offset int DEFAULT 0 +) RETURNS SETOF public.assets AS $$ + SELECT * FROM public.assets a + WHERE a.review_status = 'approved' + AND a.deleted_at IS NULL + AND (p_kind IS NULL OR a.kind = p_kind) + AND (p_semantic_class IS NULL OR a.semantic_class = p_semantic_class) + AND (p_style_tag IS NULL OR a.style_tag = p_style_tag) + AND (p_vol_lo IS NULL OR a.volume_m3 BETWEEN p_vol_lo AND p_vol_hi) + AND (p_hue IS NULL OR hue_diff_360(hue_of_hex(a.dominant_color), p_hue) <= p_hue_tol) + AND (p_q IS NULL OR a.name ILIKE '%'||p_q||'%' OR a.tags && string_to_array(p_q, ',')) + ORDER BY a.trust_score DESC, a.use_count DESC + LIMIT p_limit OFFSET p_offset; +$$ LANGUAGE sql STABLE; +``` + +- **性能目标**:100k 行规模下,`semantic_class + bbox_filter + style_tag` 三维过滤 + 排序 ≤ 200 ms(依赖 §2.4 复合索引) +- **CDN 缓存**:60 s(按 query string 哈希);登录态附 `Cache-Control: private` + +### 6.3 错误码增量 + +**建议增量** to [`02_api_contract.md`](02_api_contract.md) §7: + +| 错误码 | HTTP | 含义 | 出现端点 | +|--------|------|------|----------| +| `ASSET_FORMAT_UNSUPPORTED` | 415 | 不接受 `.obj` / `.fbx` | E-A1, W-1 | +| `ASSET_OVER_BUDGET` | 413 | 三角面数或文件大小超限 | W-5 | +| `ASSET_PBR_INCOMPLETE` | 400 | 材质 PBR 字段缺失(AL-8) | W-7 | +| `ASSET_NSFW_BLOCKED` | 403 | NSFW score ≥ 0.9 自动下架 | Mod | +| `ASSET_PII_DETECTED` | 403 | 贴图含人脸/证件 | Mod | +| `ASSET_MALICIOUS` | 400 | 文件结构异常或含 embedded 脚本 | W-2 | +| `ASSET_ALREADY_REVIEWED` | 409 | 同一 asset 不允许重复审 | E-A4 | +| `ASSET_FORK_LICENSE_CONFLICT` | 400 | 父 CC-BY 子选 CC0 不允许 | E-A5 | +| `ASSET_FORK_NON_REMIXABLE` | 403 | 父资产 `kind='furniture'` 在 MVP 不允许 fork | E-A5 | +| `CREATOR_FROZEN` | 403 | `creator_trust_score < 0` 上传被冻结 | E-A1 | +| `REVIEWER_FORBIDDEN` | 403 | 无 reviewer role | E-A4 | +| `COLLECTION_NAME_TAKEN` | 409 | 同 owner 收藏夹重名 | E-A7 | +| `ASSET_ALREADY_APPROVED` | 409 | approved 资产不可走 owner DELETE | E-A8 | + +--- + +## 7. AssetPicker 排序与发现策略 + +### 7.1 默认排序综合公式 + +AssetPicker 默认查询 `order=trust_score.desc, use_count.desc`,但实际后端按下列加权重排(在 RPC `search_assets` 顶层包一层): + +``` +score = 0.4 * normalize(trust_score, 0-100) + + 0.3 * normalize(log1p(use_count), log scale clip 0-1) + + 0.2 * license_bonus -- CC0=1.0, CC-BY=0.5 + + 0.1 * recency_decay(created_at) -- 7 天内 1.0, 30 天 0.5, 90 天 0.2 + - 1.0 * is_reported -- 当前有未结举报扣分(强烈降权) +``` + +### 7.2 冷启动策略 + +| 阶段 | 规则 | +|------|------| +| 上传后 0-7 天 | `trust_score` 固定 30,禁止变化;优先暴露给 Remix 编辑器底部「新上传推荐」窗口(占 10% 流量配额) | +| 第 8 天 | 根据 `download_count / use_count / report_count` 进入 §8 信任分动态算法 | +| 资产首次被 use 时 | 立即将 `trust_score += 2`(实时反馈),但单 7 天窗口内增量上限 ≤ 10 | + +### 7.3 三类资产权重差异 + +| `source_type` | 默认 `trust_score` | 排序额外因子 | +|---------------|-------------------|-------------| +| `platform` | 80 | × 1.0 | +| `user_upload` | 30 → 动态 | × 0.85(同分数时让位 platform) | +| `remix_of` | round(parent × 0.8) | × 0.7(避免刷量) | + +### 7.4 防作弊 + +- 同 IP 同 asset 24h 内 use_count 增量上限 = 3(trigger 端去重) +- 同 uploader 自我点赞收藏不计 use_count +- `trust_score` 24h 内变化幅度 ≤ ±15(防恶意刷分/被刷) + +--- + +## 8. 创作者声誉与信任分(AL 衍生需求) + +### 8.1 双重信任分模型 + +| 维度 | 字段 | 范围 | 含义 | +|------|------|------|------| +| **资产级** | `assets.trust_score` | 0-100 | 单件资产的可信度,影响 §7 排序 | +| **创作者级** | `users.creator_trust_score` | -100 ~ 100 | 创作者整体声誉,影响上传权限与跳审 | + +### 8.2 `creator_trust_score` 计分规则 + +| 事件 | 变化 | 触发位置 | +|------|------|---------| +| 初始注册 | = 50 | `users` 默认值 | +| 资产 approve | +5 | E-A4 action=approve | +| 资产 reject | -10 | E-A4 action=reject | +| 资产被举报且 reviewer 判定违规 | -20 | `asset_reports.resolution='confirmed_violation'` | +| 资产被 use_count 累计 ≥ 100 | +10 | trigger(每件资产仅奖励一次) | +| 资产被收藏(加入 `asset_collections`)累计 ≥ 100 次 | +5 | trigger | +| 主动撤回 pending 资产 | 0 | E-A8 | +| 申诉胜诉(rejected → approved) | +15 | reviewer 修正 | + +### 8.3 阈值规则 + +| 区间 | 状态 | 上传体验 | +|------|------|---------| +| `< 0` | **冻结** | E-A1 直接 `CREATOR_FROZEN`;不再接受上传 | +| `0 ~ 29` | 低信任 | 上传**必须**走人工预审(自动初筛不能放行;优先级降至 priority=7) | +| `30 ~ 79` | 标准 | 默认流程 | +| `80 ~ 100` | **Trusted Creator** | 上传可**跳过**人工审核(仅自动审,approved 后直接上架);`creator_badge='trusted'` | +| `≥ 95` 且 `creator_uploaded_count ≥ 50` | **Pro Creator** | 同上 + 优先展示在「创作者推荐」位 | + +### 8.4 与 v0.2 既有信任分系统的对齐 + +本节是 [`10_governance.md`](10_governance.md) §3.2 P1 信任分系统的**具体落地**: + +| v0.2 §3.2 P1 规则 | 本节对应 | +|-------------------|---------| +| 初始信任分 50 | `users.creator_trust_score DEFAULT 50` | +| 每次成功举报 +5 | §8.2「资产被举报判违规 -20」是被举报方;举报方 +5 走 [`10_governance.md`](10_governance.md) 现有 user-level 信任分(独立维度),不在本表 | +| 满 100 分可申请志愿者审核团 | `creator_trust_score = 100 AND creator_uploaded_count >= 50` → 弹申请入口;具体审核团 SOP 走 v0.5 | + +--- + +## 9. 版权与协议管理(AL-7 落地) + +### 9.1 MVP 与 v0.5 的两阶段策略 + +| 阶段 | 用户可选 license | UX | +|------|----------------|-----| +| **MVP(v0.2~v0.4)** | 仅 CC0 | 上传向导 §5.1.3 Step 4 默认且禁用切换;显示「MVP 期所有资产强制 CC0 — 详见 [社区准则 §4](../CrowdRoom/10_governance.md#4-web-移交契约p-w-3--p-w-6-逐条决策)」 | +| **v0.5+(DAU > 5 000)** | CC0 或 CC-BY-4.0 | 上传向导 Step 4 加单选;选 CC-BY 必填「原作者名 + 原始 URL」(即便是自己原创,URL 可填自己主页) | + +### 9.2 强约束(与 [`10_governance.md`](10_governance.md) §4 P-W-4 一致) + +- ✅ **接受**:CC0、CC-BY-4.0(v0.5+) +- ❌ **永不接受**: + - **CC-BY-SA**(病毒条款,污染 Remix 树) + - **CC-BY-NC**(与 CrowdRoom 平台 BY-NC 双重叠加,堵死 Remix 商业化) + - **任何商业素材**(涉及税务、对账、退款,超 MVP 预算) + +### 9.3 attribution 自动透传链路 + +```mermaid +flowchart LR + A[CC-BY 资产 A] --> Remix[Remix 引用 A] + Remix --> Overlay[remix_overlay.json 自动追加 attribution: { asset_id, creator, license }] + Overlay --> RemixPage[Remix 详情页页脚 Assets in this remix] + RemixPage --> Display[显示 by creator CC-BY 4.0] +``` + +### 9.4 侵权举报与下架流程 + +``` +用户举报(E-A6) + | + v +asset_reports INSERT, 累计独立举报数 >= 3 + | + v +trigger: assets.review_status = 'pending' (自动隐藏) + + asset_review_queue INSERT priority=1 + + Edge Function 通知 uploader 站内信 + | + v +Reviewer 24h 内决策 + |--- false_report → review_status='approved' 恢复 + |--- confirmed_violation → review_status='withdrawn' + + Storage 文件物理删除 + + uploader.creator_trust_score -= 20 + + 触发回退:所有 remix_overlay 引用此 asset_id 的 op + 自动 fallback 到「占位 box」(asset_id 仍可在 overlay 中保留以备申诉) +``` + +### 9.5 「占位 box」fallback 规则 + +- Web 端加载 overlay 时,对每个 `op.asset_id` 调 E-A3 校验存在; +- 若 asset 已 `withdrawn` 或 `deleted`,渲染端用一个灰色 OBB 占位 mesh 替代(尺寸取自原 `target_item.obb`),上方浮一行 `"Asset withdrawn"` +- 与 [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §9 `applyOverlay` 的「asset 加载失败兜底」分支兼容 + +--- + +## 10. 资产收藏 / 集合 / 个人主页(社区飞轮) + +### 10.1 用户收藏夹(`asset_collections`,`is_official=false`) + +- 用户可建多个收藏夹(如 "我的客厅灵感"、"侘寂风备选") +- 每个收藏夹 `asset_ids[]` 上限 200 个;超出走「分页拆分」CTA +- 收藏夹可 `is_public=true` 公开,被发现页「灵感板」抓取展示 +- 在 AssetPicker 顶栏可一键「+ 加入收藏夹」(弹出收藏夹选择 + 新建) + +### 10.2 官方风格集(`is_official=true`) + +- 仅 reviewer / owner-team 可创建(service_role 写) +- 典型示例: + - "侘寂风家具 30 件套"(30 件 `style_tag='wabi_sabi'` 的精选) + - "Quaternius Spring 2026 新品" + - "亚洲设计师精选"(v0.5+ 创作者 spotlight) +- 发现页 `/` 顶部「编辑精选」位轮播展示 + +### 10.3 创作者个人主页扩展 + +扩展 [`04_web_app_plan.md`](04_web_app_plan.md) §1.1 R-4 `/u/[handle]` 既有页: + +| 新增模块 | 内容 | +|---------|------| +| 「我的资产」Tab | 网格列出该用户 `approved` 资产;右上 stats:上传数 / 总使用数 / 总下载数 | +| Trusted Creator 徽章 | 头像右下角小盾牌(`creator_badge='trusted'`) | +| Pro Creator 徽章 | 头像右下角金色齿轮(`creator_badge='pro'`) | +| 「联系/赞赏」按钮 | v0.5+;MVP 隐藏 | +| 收藏夹列表(仅自己可见私有) | 公开收藏夹列出 | + +--- + +## 11. 移动端可访问性(iOS App 是否能上传资产?) + +### 11.1 决策:MVP iOS App **不支持**上传资产到资产库 + +| 维度 | 理由 | +|------|------| +| **用户画像** | iOS 端用户的核心动作是「扫描房间」(RoomPlan),不是「建模家具」 | +| **工具链** | 自制 .glb 的工具链都在 PC(Blender / 3ds Max / Rhino),手机上无创作场景 | +| **审核成本** | 手机端上传更容易出现"随手拍照伪装 3D"的低质内容,自动初筛拦不住 | +| **开发成本** | iOS 端走 §5 完整上传向导需重写 SwiftUI;MVP 期 ROI 低 | + +### 11.2 v0.5 起的轻量入口(候选) + +- iPhone 拍照 → Object Capture(iOS 17+)→ 自动生成 .glb → 走简化版 §5 流程 +- 仅支持「桌面摆件」级小物体(< 30 cm 边长) +- 自动 `style_tag='unknown'`,强制进入 priority=2 人工审 + +### 11.3 iOS App 仍可以做的资产相关动作 + +| 动作 | 是否支持 | +|------|---------| +| 浏览资产库(只读) | ✅(MVP 即支持,复用 Web 的 E-A3) | +| 收藏资产到 `asset_collections` | ✅ | +| 举报资产(E-A6) | ✅ | +| 上传资产 | ❌(MVP)→ ✅(v0.5 Object Capture 路径) | +| Fork / Remix 资产 | ❌(MVP)→ ✅(v0.5) | + +--- + +## 12. 性能与存储成本 + +### 12.1 存储成本估算 + +| 资产数 | 平均单件 | 总存储 | Cloudflare R2 月成本 | Supabase Storage 月成本 | +|--------|---------|--------|--------------------|----------------------| +| 1 万 | 600 KB(model+thumb+preview) | ~6 GB | ~$0.10 | 免费档内 | +| 10 万 | 600 KB | ~60 GB | ~$1.50 | $1.25(超 1 GB 后 $0.021/GB) | +| 100 万 | 600 KB | ~600 GB | ~$15 | $12.6 → 此规模迁 R2 | + +> 与 [`04_web_app_plan.md`](04_web_app_plan.md) §10 / [`10_governance.md`](10_governance.md) §4 P-W-6「Cloudflare 免费档 + R2 出口费」一致;MVP 期资产库不会成为成本瓶颈。 + +### 12.2 AssetPicker 加载性能 + +| 场景 | 目标 | 实现 | +|------|------|------| +| 第一屏 20 个卡片 | TTI < 1 s | 20 × 128×128 webp ≈ 200 KB(CDN gzip 后),4G 网络 < 800 ms | +| 滚动加载下一屏 | < 300 ms | E-A3 `limit=20` + offset 分页,CDN 60s 缓存命中率 > 80% | +| 详情页 360° 预览 | TTI < 2 s | 预加载 model.glb(≤ 1 MB,CDN)+ 异步加载 preview_3d.glb | + +### 12.3 检索性能 + +| 规模 | 三维过滤(semantic+style+bbox)| 索引 | +|------|------------------------------|------| +| 10k 行 | ~30 ms | btree | +| 100k 行 | ~120 ms | btree + GIN | +| 1M 行 | ~400 ms(**警戒**) | 需要切 ElasticSearch / Meilisearch(P2,纳入 ROADMAP) | + +> **触发条件**:总 `approved` 资产数 > 100k → 启动 outbox 同步到 Meilisearch;保留 Postgres 为权威源(与 [`01_data_schema.md`](01_data_schema.md) §1 D5 全文搜索演进策略一致)。 + +### 12.4 Worker 处理能力 + +| 资源 | 单次处理时间 | MVP 并发 | +|------|------------|---------| +| 用户上传 .glb(5 MB → 1 MB) | ~30 s(含 KTX2 + CLIP + 缩略图) | 5 并发,60 件/小时 | +| 平台 import 批量 | ~10 s/件 | 串行,360 件/小时(夜间跑) | + +MVP 容量:60 件/小时 × 24 h = 1 440 件/日上限,远高于预期 50 件/日新增。 + +--- + +## 13. 与 v0.2 设计的关系矩阵 + +> 一张表,每行:本文档章节 / 关联的 v0.2 文档章节 / 关系类型。Reviewer 可一眼看清哪些是新增、哪些是已有。 + +| 本文档章节 | 关联 v0.2 章节 | 关系类型 | +|-----------|--------------|---------| +| §1 三类资产定位 | [`10_governance.md`](10_governance.md) §4 P-W-4 | **深化补充**(从"全 CC0 公共池"扩到三源流) | +| §2 `assets` 表 13 列增量 | [`01_data_schema.md`](01_data_schema.md) §3.9 | **建议增量**(ALTER 不改既有列) | +| §2.7.1 `asset_review_queue` 新表 | [`10_governance.md`](10_governance.md) §2.1 自动审核流 | **新增能力**(资产分支队列) | +| §2.7.2 `asset_reports` 新表 | [`02_api_contract.md`](02_api_contract.md) §2 E-14 通用 report 端点 | **深化补充**(资产专属举报 schema) | +| §2.7.3 `asset_collections` 新表 | — | **新增能力**(v0.2 无收藏概念) | +| §2.7.4 `creator_trust_score` 字段 | [`10_governance.md`](10_governance.md) §3.2 P1 信任分 | **深化补充**(具体落地) | +| §3 Storage 目录 `public/assets/{id}/` | [`01_data_schema.md`](01_data_schema.md) §4 | **建议增量**(顶层 prefix 扩展) | +| §3.3 `glb_path_immutable` trigger | [`01_data_schema.md`](01_data_schema.md) §3.9 | **建议增量**(AL-9 兜底) | +| §4 平台导入管线 | [`10_governance.md`](10_governance.md) §4 P-W-4 步骤 1-3 | **深化补充**(提供具体脚本骨架) | +| §5 用户上传向导 | [`04_web_app_plan.md`](04_web_app_plan.md) §1.1 路由表 | **建议增量**(R-18~R-19a 路由) | +| §5.2 Asset Worker | [`02_api_contract.md`](02_api_contract.md) §3 Transcode Worker | **深化补充**(复用基础设施,新增分支) | +| §5.3 审核工作台 | [`04_web_app_plan.md`](04_web_app_plan.md) R-17 `/admin/reports` | **建议增量**(R-17b `/admin/assets`) | +| §6 8 个新端点 E-A1~E-A8 | [`02_api_contract.md`](02_api_contract.md) §2.1 端点表 | **建议增量** | +| §6.3 错误码增量 | [`02_api_contract.md`](02_api_contract.md) §7 | **建议增量** | +| §7 AssetPicker 排序 | [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §4 | **深化补充** | +| §8 创作者信任分 | [`10_governance.md`](10_governance.md) §3.2 | **深化补充**(具体公式) | +| §9 协议管理 | [`10_governance.md`](10_governance.md) §4 P-W-4、§8 | **直接引用 + 深化** | +| §9.5 「占位 box」fallback | [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §9 / §12 F-5 | **直接引用** | +| §10 收藏 / 集合 | [`04_web_app_plan.md`](04_web_app_plan.md) §1.1 R-4 用户主页 | **建议增量** | +| §11 iOS 边界 | [`03_ios_app_plan.md`](03_ios_app_plan.md) §1 路由 | **新增决策**(明确不做) | +| §12 性能 / 成本 | [`04_web_app_plan.md`](04_web_app_plan.md) §10、[`10_governance.md`](10_governance.md) §4 P-W-6 | **深化补充** | + +--- + +## 14. M1 验收清单 + +### 14.1 12 条 checkbox 验收(M1 = MVP 8 周窗口内必达) + +- [ ] **M1-01**:`assets` 表通过 §2 增量 ALTER 后能存储 1 万条平台资产(数据迁移脚本无报错) +- [ ] **M1-02**:从 Quaternius 批量导入 100 件家具脚本(§4.3)能跑通,平均单件 < 15 s +- [ ] **M1-03**:用户能从 `/me/assets/new` 上传 1 个 .glb 文件(5 步向导 UX 完整) +- [ ] **M1-04**:上传完成后 5 分钟内进入审核队列(`asset_review_queue` 有对应行 priority=5) +- [ ] **M1-05**:自动初筛能拒 NSFW(score ≥ 0.9)/ 拒大于 50k 三角面数 / 拒含人脸贴图 +- [ ] **M1-06**:审核员在 `/admin/assets` 工作台 approve 后,资产能在 AssetPicker 中通过 E-A3 被搜到 +- [ ] **M1-07**:AssetPicker 按 `semantic_class + bbox_filter + style_tag` 复合查询响应 < 200 ms(100k 行规模) +- [ ] **M1-08**:[`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §15 的 **AL-1 ~ AL-10 全部 10 条契约**逐条达成(详见 §14.2 对照表) +- [ ] **M1-09**:资产被 use_count 累计 ≥ 100 后 `assets.trust_score` 自动 +10(trigger 实测) +- [ ] **M1-10**:用户能从 `/me/assets` 创建收藏夹并放入 ≥ 5 个资产,公开后他人可见 +- [ ] **M1-11**:CC-BY 资产(v0.5+ 启用)在 Remix 详情页底部正确显示「Asset by {creator} (CC-BY 4.0)」 +- [ ] **M1-12**:侵权举报触发后 24 h 内(confirmed_violation 路径)能从 AssetPicker 下架;占位 box fallback 生效 + +### 14.2 AL-1 ~ AL-10 契约最终对照表(双重显式,与 §2.6 互为冗余) + +| 契约 | 验收条件 | 测试方法 | M1 状态 | +|------|---------|---------|---------| +| **AL-1** | 所有 `kind='furniture'` 资产 `anchor_local` + `bbox_local` 非空率 100% | `SELECT count(*) FROM assets WHERE kind='furniture' AND (anchor_local='{}' OR bbox_local='{}')` = 0 | [ ] | +| **AL-2** | `semantic_class` 非空率 ≥ 99%([`01_data_schema.md`](01_data_schema.md) §3.9 既有字段) | 同上 SQL 校验 | [ ] | +| **AL-3** | `style_tag` 非空率 ≥ 95%;7 类 enum 覆盖 | `SELECT style_tag, count(*) FROM assets GROUP BY 1` | [ ] | +| **AL-4** | `volume_m3 > 0` 非空率 100%;`dominant_color` 非空率 ≥ 95%;GIN tags 索引存在 | `\d+ public.assets` 看索引 + 数据校验 | [ ] | +| **AL-5** | 100% 平台资产 `model.glb` ≤ 1 MB(家具)/ ≤ 200 KB(材质) | Storage 对象大小巡检脚本 | [ ] | +| **AL-6** | 100% 资产有 `thumb_128.webp`(128×128) | Storage 路径存在性巡检 | [ ] | +| **AL-7** | `license CHECK IN ('CC0','CC-BY-4.0')` 强制;100% 资产协议 = CC0(MVP 阶段) | `SELECT DISTINCT license FROM assets` | [ ] | +| **AL-8** | `kind='material'` 资产 100% 含 `pbr.base_color_tex / normal_tex / roughness / metallic` | JSONB key existence SQL | [ ] | +| **AL-9** | `assets_glb_path_lock` trigger 启用;任何 UPDATE 改 `glb_path` 都抛错 | 故意 UPDATE 测试 | [ ] | +| **AL-10** | E-A3 接受 `bbox_filter / style / semantic / color` 复合参数;响应 ≤ 200 ms | k6 压测 100k 行规模 | [ ] | + +> **结论**:14.2 对照表 + §2.6 字段映射表共同实现 AL-1 ~ AL-10 的**双重显式**对账。 + +--- + +## 15. 风险与开放问题 + +| # | 风险 | 影响 | 缓解策略 | +|---|------|------|---------| +| **R-1** | UGC 资产质量参差 → AssetPicker 体验下降 | 用户找不到好资产,Remix 转化率下降 | §7 排序公式让 platform 资产默认排前;冷启动 7 天压制 user_upload;§8 信任分淘汰低质创作者 | +| **R-2** | 版权审核难度大(用户「原创」声明无法机器验证) | 法律风险,版权方追责 | MVP 强制 CC0(用户放弃所有权);侵权举报 24 h 内下架 + Storage 删除 + 占位 box fallback;保留申诉通道 | +| **R-3** | 资产规模 > 100k 后检索性能退化 | E-A3 响应 > 200 ms,AssetPicker 卡顿 | §12.3 触发条件:> 100k 行 → outbox 同步到 Meilisearch;Postgres 保留为权威源(P2,纳入 ROADMAP) | +| **R-4** | 平台资产爬取的法律边界(Sketchfab CC0 标错率高) | 误把非 CC0 当 CC0,被原作者追责 | §4.1 Sketchfab 子集**每件单独人工 verify**;保留 source_url + attribution.txt 即便 CC0 | +| **R-5** | Remix 衍生的协议传染(CC-BY 链可能很长) | 长链署名累积,UI 难以全部展示 | §9.3 attribution 透传链路 trigger 自动汇总;详情页页脚折叠展示「View all attributions」 | +| **R-6** | iOS 不能上传资产是否伤害创作者生态 | 移动端创作者流失 | §11.2 v0.5 引入 Object Capture 轻量入口;MVP 阶段通过「Web 上传 + 邮件分享给 iOS 用户」过渡 | + +--- + +**章节版本**:v0.2 · 草案(子任务 10 产出,2026-05-19) +**关键收获**: +- v0.2 既有 `assets` 表 13 列字段增量 + 3 张支撑表 = 完整的「平台 + UGC + Remix」三源流资产库 +- 8 个新端点 E-A1~E-A8 + 1 个 RPC `search_assets` = AssetPicker 复合检索 + UGC 全生命周期 +- 10 条 AL 契约 100% 落地(§2.6 字段映射 + §14.2 验收对照 双重显式) +- 与 [`10_governance.md`](10_governance.md) §4 P-W-4 决策严格一致(MVP 全 CC0,v0.5 起 CC-BY) + +**对外契约**: + +| 契约 | 给谁 | 一句话 | +|------|------|--------| +| **`assets` 表 13 列 ALTER + 3 张支撑表** | DBA / 后端 | 在 v0.2 §3.9 既有 schema 上做幂等 ALTER;不破坏既有迁移 | +| **8 个新端点 E-A1~E-A8** | iOS / Web / 运营 | 全部走 Edge Function 或 PostgREST,沿用 v0.2 鉴权/限流维度 | +| **AL-1 ~ AL-10 全部 10 条契约达成** | [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) 物品替换闭环 | 资产库提供的元数据足以让 AssetPicker 在 200 ms 内做 OBB 对齐替换 | +| **MVP CC0 强制 / v0.5 CC-BY 二选一** | 法务 / 运营 | 与 [`10_governance.md`](10_governance.md) §4 P-W-4 决策一致 | +| **iOS App 仅消费不生产** | iOS 团队 | MVP 不接 UGC 上传;v0.5 起评估 Object Capture 轻量入口 | + + + diff --git a/plans/CrowdRoom/CHANGELOG.md b/plans/CrowdRoom/CHANGELOG.md new file mode 100644 index 0000000..b70f76d --- /dev/null +++ b/plans/CrowdRoom/CHANGELOG.md @@ -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-2(E-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 张表 DDL(v0.2 升至 9 张是 G-1 拆表的结果,v0.1 时为 8 张含 `room_versions`)、RLS 策略、Storage 公开/私有双桶目录、`layer_manifest.json` JSON Schema、CrowdRoom 4 层 ↔ PRISM L1–L4 映射。 +- [`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 周实施的全部输入。 diff --git a/plans/CrowdRoom/EXECUTION_PLAN.md b/plans/CrowdRoom/EXECUTION_PLAN.md new file mode 100644 index 0000000..0359f19 --- /dev/null +++ b/plans/CrowdRoom/EXECUTION_PLAN.md @@ -0,0 +1,1080 @@ +# CrowdRoom · 基于 GitHub 成熟开源项目的工程执行方案 + +> **文档版本**:v0.1 · 2026-05-19 +> **承接设计**:[`README.md`](README.md) / [`00_overview.md`](00_overview.md) / [`01_data_schema.md`](01_data_schema.md) / [`02_api_contract.md`](02_api_contract.md) / [`03_ios_app_plan.md`](03_ios_app_plan.md) / [`04_web_app_plan.md`](04_web_app_plan.md) / [`09_privacy.md`](09_privacy.md) / [`10_governance.md`](10_governance.md) / [`ROADMAP.md`](ROADMAP.md) +> **文档目的**:把已锁定的 v0.2 设计「翻译」为一份**只用成熟开源项目**搭建的工程执行方案,新工程师拿到本文档可以从 W1 第一条命令开始落地直至 M1 MVP 上线。 + +--- + +## 1. 执行方案总览 + +### 1.1 一句话定义 + +> 本方案承诺:**全部使用 ≥ 1k star、Apache-2.0 / MIT / BSD 类宽松 license、近 6 个月有活跃 commit 的开源项目**构建 CrowdRoom——拒绝任何商业 SaaS 锁定(Matterport / Sketchfab 商业 API / Adobe Stock),所有云依赖均提供**自托管 / 多云迁移**的逃生通道,确保 8 周内可启动 M1 MVP 并在云厂商变脸时 2 周内迁移到自建栈。 + +### 1.2 五条工程原则 + +1. **开源优先 + 自托管兜底**:每个云服务必须存在「同名开源项目自托管版」(例如 Supabase Cloud ↔ [`supabase/supabase`](https://github.com/supabase/supabase) 自托管)。 +2. **monorepo + workspace**:iOS / Web / Worker / Schemas 同仓共版本,避免「契约漂移」。 +3. **TypeScript 一统服务端**:Edge Functions(Deno)+ Worker(Node)+ Web(Next.js)共享同一份 [`packages/shared-types/`](packages/shared-types/) 的 Zod schema。 +4. **CDN-first**:所有公开资产走 Cloudflare R2 + CDN,CrowdRoom 服务端永不承担静态分发流量。 +5. **可测试性即合规**:CI 不绿不许合并;端到端测试覆盖 [`02_api_contract.md`](02_api_contract.md) §8 的 11 条契约(X1-X5 + Y1-Y6)。 + +### 1.3 总体技术拓扑(每节点标注 GitHub repo) + +```mermaid +graph TB + subgraph CLIENT[客户端层] + IOS[iOS App
apple/swift + supabase-community/supabase-swift] + WEB[Web App
vercel/next.js + pmndrs/react-three-fiber] + end + + subgraph BAAS[Supabase BaaS] + AUTH[Auth
supabase/gotrue] + DB[(Postgres 15
supabase/postgres)] + STG[Storage
supabase/storage-api] + EDGE[Edge Functions
denoland/deno] + RT[Realtime
supabase/realtime] + end + + subgraph WORKER[Transcode Worker] + QUEUE[BullMQ
taskforcesh/bullmq] + TRANS[gltf-transform
donmccurdy/glTF-Transform] + USD[Apple usdzconvert
via macOS runner] + SHOT[Headless render
microsoft/playwright] + SHARP[Thumbnails
lovell/sharp] + end + + subgraph MOD[审核 / 治理] + NSFW[infinitered/nsfwjs] + SENS[houbb/sensitive-word] + end + + subgraph CDN_DEPLOY[CDN + 部署] + R2[Cloudflare R2
S3 兼容] + VERCEL[Vercel
Next.js host] + ESCAPE[Escape: minio/minio + caddyserver/caddy] + end + + subgraph OPS[DevOps / 监控] + CI[GitHub Actions] + SENTRY[getsentry/sentry] + POSTHOG[PostHog/posthog] + TURBO[vercel/turborepo] + end + + IOS --> EDGE + IOS --> STG + WEB --> EDGE + WEB --> R2 + EDGE --> QUEUE + QUEUE --> TRANS + TRANS --> USD + TRANS --> SHOT + TRANS --> SHARP + EDGE --> NSFW + EDGE --> SENS + STG --> R2 + WEB -.deploy.- VERCEL + OPS -.- CI +``` + +--- + +## 2. 开源依赖选型清单(核心章节,按层组织) + +> **图例**:星数采用粗略量级(≥ 1k / ≥ 10k / ≥ 50k);license 列若标 `Apache-2.0/MIT` 表示主体 + 文档采用宽松双协议;「最近 commit」均确认 ≤ 6 个月内活跃。 + +### 2.1 iOS App 层(10 个 repo) + +| 组件 | GitHub repo | Star | License | 最近 commit | 选型理由 | 备选方案 | +|---|---|---|---|---|---|---| +| Supabase Swift SDK | [`supabase-community/supabase-swift`](https://github.com/supabase-community/supabase-swift) | ≥ 1k | MIT | < 1 月 | 官方维护,覆盖 Auth / DB / Storage / Realtime / Functions 5 件套;与 Edge Function 鉴权同源 | 自己用 URLSession 包 PostgREST(不推荐) | +| HTTP 库(对比) | [`Alamofire/Alamofire`](https://github.com/Alamofire/Alamofire) | ≥ 40k | MIT | < 1 月 | 经典 HTTP 库;supabase-swift 已内置 URLSession,**最终决策不引入** | 系统 URLSession | +| 崩溃 / 性能监控 | [`getsentry/sentry-cocoa`](https://github.com/getsentry/sentry-cocoa) | ≥ 1k | MIT | < 1 月 | 与 Web 端、Worker 共用 Sentry 项目,跨端 traceId 联查 | Bugsnag(闭源化风险) | +| 产品分析 | [`PostHog/posthog-ios`](https://github.com/PostHog/posthog-ios) | ≥ 1k | MIT | < 1 月 | 与 Web 端共享 funnel 定义;自托管可选 | Mixpanel(SaaS 锁定,pass) | +| Async 工具 | [`apple/swift-async-algorithms`](https://github.com/apple/swift-async-algorithms) | ≥ 3k | Apache-2.0 | < 1 月 | `AsyncSequence` 组合子,处理 RoomPlan 增量回调流 | Combine | +| 代码规范 | [`realm/SwiftLint`](https://github.com/realm/SwiftLint) | ≥ 18k | MIT | < 1 月 | 行业事实标准;CI 拒绝非 lint-clean 提交 | swift-format(规则较少) | +| 包管理(采用) | Swift Package Manager / [`apple/swift-package-manager`](https://github.com/apple/swift-package-manager) | ≥ 9k | Apache-2.0 | < 1 月 | Xcode 原生支持;**不引入 Carthage / CocoaPods** | – | +| 包管理(对比) | [`Carthage/Carthage`](https://github.com/Carthage/Carthage) | ≥ 14k | MIT | < 6 月 | 仅作历史方案对比 | – | +| 持久化 | [`groue/GRDB.swift`](https://github.com/groue/GRDB.swift) | ≥ 6k | MIT | < 1 月 | iOS 本地草稿 / 上传队列 SQLite 封装;崩溃恢复 | Core Data(学习曲线陡) | +| USD 解析(关键空白) | [`PixarAnimationStudios/OpenUSD`](https://github.com/PixarAnimationStudios/OpenUSD) | ≥ 6k | Apache-2.0 / TOST | < 1 月 | 仅在 macOS CI runner 上用其 Python 绑定做 `.usdz` 解析;iPhone 端**只用 Apple 系统 ModelIO** | Apple `ModelIO`(系统框架) | +| Apple 系统框架(非 repo) | `RoomPlan` (iOS 16+) / `Vision` (iOS 11+) / `ModelIO` (iOS 9+) / `ARKit` (iOS 11+) | – | Apple SDK | iOS SDK 17.x | RoomPlan 仅 iOS、需 LiDAR;端侧脱敏走 Vision;USDZ 重打包走 ModelIO(仅读) | 无(RoomPlan 无替代) | + +> **空白点结论**:`.usdz` 在 iPhone 端**只用 ModelIO 读不重打包**——端侧脱敏只对纹理图像做 `CIGaussianBlur`,最终 `.usdz` 重写交给服务端 Worker 的 macOS runner 跑 `usdzconvert`。这与 [`09_privacy.md`](09_privacy.md) §1 PR-1「端侧脱敏不可降级」对齐:**纹理(图像)端侧改,容器(USDZ 二进制)服务端重打**。 + +### 2.2 Web App 层(16 个 repo) + +| 组件 | GitHub repo | Star | License | 最近 commit | 选型理由 | 备选方案 | +|---|---|---|---|---|---|---| +| Web 框架 | [`vercel/next.js`](https://github.com/vercel/next.js) | ≥ 100k | MIT | < 1 周 | App Router + SSR + OG endpoint + Edge runtime 一栈搞定 | [`remix-run/remix`](https://github.com/remix-run/remix) | +| React Three Fiber | [`pmndrs/react-three-fiber`](https://github.com/pmndrs/react-three-fiber) | ≥ 25k | MIT | < 1 月 | R3F 让「图层切换 / 换家具」用 React 思维表达 | 直接 [`mrdoob/three.js`](https://github.com/mrdoob/three.js) | +| Drei 工具集 | [`pmndrs/drei`](https://github.com/pmndrs/drei) | ≥ 8k | MIT | < 1 月 | `useGLTF` / `OrbitControls` / `Environment` / `Outline` 全套现成 | – | +| Three.js 底层 | [`mrdoob/three.js`](https://github.com/mrdoob/three.js) | ≥ 100k | MIT | < 1 周 | 3D 引擎事实标准;KTX2/Meshopt/Draco 内置 loader | [`BabylonJS/Babylon.js`](https://github.com/BabylonJS/Babylon.js) | +| 状态管理 | [`pmndrs/zustand`](https://github.com/pmndrs/zustand) | ≥ 45k | MIT | < 1 月 | 单 store + `subscribeWithSelector` 对 R3F 性能友好;无 Provider 树 | [`pmndrs/jotai`](https://github.com/pmndrs/jotai) | +| Tailwind CSS | [`tailwindlabs/tailwindcss`](https://github.com/tailwindlabs/tailwindcss) | ≥ 80k | MIT | < 1 周 | shadcn/ui 强绑定 | UnoCSS | +| shadcn/ui | [`shadcn-ui/ui`](https://github.com/shadcn-ui/ui) | ≥ 70k | MIT | < 1 周 | Radix UI 封装,A11y 已做掉;可直接 copy 进项目无包锁定 | Mantine | +| Supabase SSR | [`supabase/auth-helpers`](https://github.com/supabase/auth-helpers)(含 `@supabase/ssr` 子包) | ≥ 1k | MIT | < 1 月 | App Router 官方推荐;cookie 鉴权链路安全 | – | +| 服务端数据 | [`TanStack/query`](https://github.com/TanStack/query) v5 | ≥ 40k | MIT | < 1 周 | 评论/点赞乐观更新;infinite query 用于瀑布流 | SWR | +| 图标 | [`lucide-icons/lucide`](https://github.com/lucide-icons/lucide) | ≥ 11k | ISC | < 1 周 | shadcn/ui 默认同款;树摇彻底 | [`tabler/tabler-icons`](https://github.com/tabler/tabler-icons) | +| 国际化 | [`amannn/next-intl`](https://github.com/amannn/next-intl) | ≥ 3k | MIT | < 1 月 | App Router 友好,按路由段 `/[locale]/...` 切分 | next-i18next | +| 产品分析 | [`PostHog/posthog-js`](https://github.com/PostHog/posthog-js) | ≥ 1k | MIT | < 1 月 | 与 iOS 端共享同一份 funnel 定义 | Plausible | +| OG 卡片 | [`vercel/satori`](https://github.com/vercel/satori)(含 `@vercel/og`) | ≥ 10k | MPL-2.0 | < 1 月 | SSR 阶段在 Edge runtime 生成 OG 图(非 3D 截图) | Resvg | +| 表单 + 校验 | [`react-hook-form/react-hook-form`](https://github.com/react-hook-form/react-hook-form) + [`colinhacks/zod`](https://github.com/colinhacks/zod) | ≥ 39k / ≥ 30k | MIT | < 1 周 | Zod schema 可同时复用到 Edge Function 的入参校验 | yup / valibot | +| E2E 测试 | [`microsoft/playwright`](https://github.com/microsoft/playwright) | ≥ 60k | Apache-2.0 | < 1 周 | 既做 E2E,又用于 OG 截图 worker;可跑 WebGL | [`cypress-io/cypress`](https://github.com/cypress-io/cypress) | +| AI 备选位 | [`vercel/ai`](https://github.com/vercel/ai) | ≥ 8k | Apache-2.0 | < 1 月 | **MVP 不引入**;P2 做 AI 配色 / 风格推荐时启用 | – | + +### 2.3 后端 / BaaS 层(8 个 repo) + +| 组件 | GitHub repo | Star | License | 最近 commit | 选型理由 | 备选方案 | +|---|---|---|---|---|---|---| +| Supabase 主仓 | [`supabase/supabase`](https://github.com/supabase/supabase) | ≥ 65k | Apache-2.0 | < 1 周 | 云 + 自托管同一份代码,**这是云锁定逃生的根本保障** | [`appwrite/appwrite`](https://github.com/appwrite/appwrite) | +| Auth 服务 | [`supabase/gotrue`](https://github.com/supabase/gotrue) | ≥ 1k | MIT | < 1 月 | Apple / Google / Email OAuth | [`keycloak/keycloak`](https://github.com/keycloak/keycloak) | +| Storage 服务 | [`supabase/storage-api`](https://github.com/supabase/storage-api) | ≥ 1k | Apache-2.0 | < 1 月 | S3 兼容;可直接换底层为 R2 / MinIO | – | +| Realtime | [`supabase/realtime`](https://github.com/supabase/realtime) | ≥ 6k | Apache-2.0 | < 1 月 | Postgres LISTEN/NOTIFY + Phoenix Channels;转码进度推送 | socket.io | +| Postgres 元数据 | [`supabase/postgres-meta`](https://github.com/supabase/postgres-meta) | ≥ 1k | Apache-2.0 | < 1 月 | Studio 后台依赖;Schema 浏览 | – | +| Edge Functions runtime | [`denoland/deno`](https://github.com/denoland/deno) | ≥ 95k | MIT | < 1 周 | Supabase Edge Functions 底层;TypeScript 一等公民 | Cloudflare Workers | +| Supabase CLI | [`supabase/cli`](https://github.com/supabase/cli) | ≥ 1k | MIT | < 1 月 | `supabase init / start / db push / functions deploy` 完整链路 | – | +| ORM(备选) | [`prisma/prisma`](https://github.com/prisma/prisma) | ≥ 38k | Apache-2.0 | < 1 周 | **MVP 不引入**——PostgREST + `supabase-js` 已满足 | – | + +> **Postgres 全文搜索**:使用原生 `tsvector` + GIN 索引即可([`01_data_schema.md`](01_data_schema.md) §1 D5),**不引入** [`zombodb/zombodb`](https://github.com/zombodb/zombodb)(依赖 ES,运维负担过重)。 + +### 2.4 转码 Worker 层(12 个 repo · 重点章节) + +| 组件 | GitHub repo | Star | License | 最近 commit | 选型理由 | 备选方案 | +|---|---|---|---|---|---|---| +| **glb 操作核心** | [`donmccurdy/glTF-Transform`](https://github.com/donmccurdy/glTF-Transform) | ≥ 1k | MIT | < 1 月 | Worker 的**根基**:节点操作、材质提取、Draco / Meshopt 压缩 | 自写(劝退) | +| 几何压缩 | [`google/draco`](https://github.com/google/draco) | ≥ 6k | Apache-2.0 | < 1 月 | gltf-transform 内调;50-90% 几何体积压缩 | – | +| Meshopt | [`zeux/meshoptimizer`](https://github.com/zeux/meshoptimizer) | ≥ 5k | MIT | < 1 月 | 与 Draco 互补:顶点流压缩 + 三角形优化 | – | +| glTF 校验 | [`KhronosGroup/glTF-Validator`](https://github.com/KhronosGroup/glTF-Validator) | ≥ 1k | Apache-2.0 | < 1 月 | Worker 产出 `canonical.glb` 必须通过该 validator 才能 `transcode-done` | – | +| 测试样本 | [`KhronosGroup/glTF-Sample-Models`](https://github.com/KhronosGroup/glTF-Sample-Models) | ≥ 2k | 多 CC | < 6 月 | CI 端到端测试夹具 | – | +| Sample Assets | [`KhronosGroup/glTF-Sample-Assets`](https://github.com/KhronosGroup/glTF-Sample-Assets) | ≥ 1k | 多 CC | < 1 月 | 子集化的资产库 | – | +| USD 工具链 | [`PixarAnimationStudios/OpenUSD`](https://github.com/PixarAnimationStudios/OpenUSD) | ≥ 6k | Apache-2.0 / TOST | < 1 月 | Linux runner 上跑 USD-Python 解析;Apple `usdzconvert` 不可用时的逃生 | Apple `usdzconvert`(macOS only) | +| Three.js 服务端参考 | [`donmccurdy/three-gltf-viewer`](https://github.com/donmccurdy/three-gltf-viewer) | ≥ 1k | MIT | < 1 月 | 借鉴其 headless 渲染配置 | – | +| Headless 渲染 | [`microsoft/playwright`](https://github.com/microsoft/playwright) | ≥ 60k | Apache-2.0 | < 1 周 | 缩略图 / OG 截图;Docker 中跑 WebGL2 | [`puppeteer/puppeteer`](https://github.com/puppeteer/puppeteer) | +| 图像处理 | [`lovell/sharp`](https://github.com/lovell/sharp) | ≥ 28k | Apache-2.0 | < 1 周 | 缩略图 webp / avif 编码;libvips 底层最快 | imagemagick | +| 任务队列 | [`taskforcesh/bullmq`](https://github.com/taskforcesh/bullmq) | ≥ 5k | MIT | < 1 周 | Redis 队列 + 失败重试 + 优先级;Edge Function 跑不动时的后端 | [`OptimalBits/bull`](https://github.com/OptimalBits/bull) | +| 向量索引(P2) | [`pgvector/pgvector`](https://github.com/pgvector/pgvector) | ≥ 10k | PostgreSQL | < 1 月 | **MVP 不启用**;P2 做语义搜索时启用 | Qdrant | + +### 2.5 内容审核 / 治理层(7 个 repo) + +| 组件 | GitHub repo | Star | License | 最近 commit | 选型理由 | 备选方案 | +|---|---|---|---|---|---|---| +| NSFW 图像分类 | [`infinitered/nsfwjs`](https://github.com/infinitered/nsfwjs) | ≥ 7k | MIT | < 6 月 | TensorFlow.js 模型;MVP 放 Worker(见 §3.5) | OpenNSFW2 (Python only) | +| 中文敏感词 DFA | [`houbb/sensitive-word`](https://github.com/houbb/sensitive-word) | ≥ 1k | Apache-2.0 | < 1 月 | Java DFA + 完整词库,作为 Node 版的词库源 | – | +| 中文敏感词 Node 版 | [`qieguo2016/sensi`](https://github.com/qieguo2016/sensi)(或 [`sxei/mint-filter`](https://github.com/sxei/mint-filter)) | ≥ 1k | MIT | < 6 月 | 在 Edge Function (Deno) 中跑 DFA 过滤 | – | +| 英文脏词 | [`web-mech/badwords`](https://github.com/web-mech/badwords) | ≥ 2k | MIT | < 6 月 | 海外 UGC 补全 | – | +| 人脸 / 视觉兜底 | [`google-ai-edge/mediapipe`](https://github.com/google-ai-edge/mediapipe) | ≥ 27k | Apache-2.0 | < 1 月 | Worker 端人脸二次检测兜底(不替代 iOS Vision) | OpenCV.js | +| OpenCV(通用图像处理) | [`opencv/opencv`](https://github.com/opencv/opencv) | ≥ 78k | Apache-2.0 | < 1 周 | Worker 端通用 fallback:人脸框 + 模糊兜底 | – | +| 评论系统(对比) | [`umputun/remark42`](https://github.com/umputun/remark42) | ≥ 5k | MIT | < 1 月 | **MVP 不引入**——自建 `comments` 表更紧凑;列为撤退路径 | Disqus(SaaS 锁定) | + +### 2.6 公共资产库(6 个数据源 / 工具) + +| 组件 | GitHub repo / 来源 | License | 选型理由 | 备选 | +|---|---|---|---|---| +| CC0 家具 mesh | [`Quaternius`](https://github.com/Quaternius)(多仓) | CC0 | 高质量 CC0 家具集合(沙发 / 床 / 餐桌全套);MVP 资产库种子集来源 | [`KhronosGroup/glTF-Sample-Assets`](https://github.com/KhronosGroup/glTF-Sample-Assets) | +| glTF 测试资产 | [`KhronosGroup/glTF-Sample-Assets`](https://github.com/KhronosGroup/glTF-Sample-Assets) | 多 CC | 转码 Worker 单测 / CI 夹具 | – | +| CC0 材质 PBR | Poly Haven([`Poly-Haven/asset-browser`](https://github.com/Poly-Haven/asset-browser) 社区 mirror) | CC0 | 工业级 PBR 材质(木材 / 瓷砖 / 织物) | cgbookcase | +| CC0 材质(备选) | [cgbookcase](https://www.cgbookcase.com) | CC0 | 补充 Poly Haven 盲区 | – | +| Free PBR(备选) | [freepbr](https://freepbr.com) | CC-BY-NC(部分) | **MVP 不用**;P1 起接 | – | +| 资产元数据脚手架 | 自研 `scripts/seed-assets/normalize.ts`(基于 gltf-transform) | – | 把 Quaternius / Poly Haven 资产统一转 `.glb` + 注入 `anchor_point` + `obb` | – | + +### 2.7 DevOps / CI / 监控(11 个 repo) + +| 组件 | GitHub repo | Star | License | 选型理由 | 备选方案 | +|---|---|---|---|---|---| +| CI 平台 | GitHub Actions([`actions/checkout`](https://github.com/actions/checkout) + [`actions/setup-node`](https://github.com/actions/setup-node) + [`actions/cache`](https://github.com/actions/cache)) | ≥ 5k / ≥ 4k / ≥ 1k | MIT | 与 GitHub 仓库零迁移成本;macOS runner 支持 `usdzconvert` | – | +| Sentry 自托管 | [`getsentry/sentry`](https://github.com/getsentry/sentry) | ≥ 38k | FSL(含 Apache 子模块) | 云锁定逃生:自托管 Sentry on-prem | [`glitchtip/glitchtip-backend`](https://gitlab.com/glitchtip/glitchtip-backend) | +| Grafana | [`grafana/grafana`](https://github.com/grafana/grafana) | ≥ 62k | AGPL-3.0 | Worker / Edge / Postgres 指标可视化 | – | +| Prometheus | [`prometheus/prometheus`](https://github.com/prometheus/prometheus) | ≥ 55k | Apache-2.0 | 时序指标采集;与 Grafana 标配 | – | +| 依赖安全扫描 | [`aquasecurity/trivy`](https://github.com/aquasecurity/trivy) | ≥ 22k | Apache-2.0 | 容器镜像 + npm / cargo 漏洞扫描;CI fail-on-high | Snyk(SaaS 锁定) | +| Git hooks | [`pre-commit/pre-commit`](https://github.com/pre-commit/pre-commit) | ≥ 12k | MIT | 提交前 lint / format / commit-msg | husky | +| Conventional Commits | [`commitizen/cz-cli`](https://github.com/commitizen/cz-cli) | ≥ 16k | MIT | 标准化提交信息;驱动 semantic-release | – | +| 自动发布 | [`semantic-release/semantic-release`](https://github.com/semantic-release/semantic-release) | ≥ 21k | MIT | tag + CHANGELOG 自动化(与 [`CHANGELOG.md`](CHANGELOG.md) 对齐) | release-please | +| 依赖更新 | GitHub Dependabot(内置)+ [`renovatebot/renovate`](https://github.com/renovatebot/renovate) | ≥ 17k | AGPL-3.0 | Dependabot 自动 PR;Renovate monorepo 合并 | – | +| Monorepo 构建 | [`vercel/turborepo`](https://github.com/vercel/turborepo) | ≥ 26k | MPL-2.0 | 远程缓存 + 受影响包检测;pnpm workspace 配套 | [`nrwl/nx`](https://github.com/nrwl/nx) | +| 本地编排 | [`docker/compose`](https://github.com/docker/compose) | ≥ 33k | Apache-2.0 | `docker-compose up` 一键启动 Supabase + Worker + Redis | – | + +### 2.8 自托管 / 撤退路径(5 个 repo · 重要) + +> 本节回答:「**如果云供应商变脸,2 周内我们能切到什么栈**」。 + +| 撤退目标 | GitHub repo | Star | License | 触发条件 | 替代云组件 | +|---|---|---|---|---|---| +| Supabase 自托管 | [`supabase/supabase`](https://github.com/supabase/supabase)(Docker Compose) | ≥ 65k | Apache-2.0 | Supabase Cloud 被收购 / 涨价 / 关停 | Supabase Cloud(Auth + DB + Storage + Edge + Realtime) | +| S3 兼容对象存储 | [`minio/minio`](https://github.com/minio/minio) | ≥ 45k | AGPL-3.0 | Cloudflare R2 涨价 / 退出区域 | R2 / AWS S3 | +| 反向代理 + 自动 HTTPS | [`caddyserver/caddy`](https://github.com/caddyserver/caddy) | ≥ 56k | Apache-2.0 | Vercel 涨价 / 区域不可达 | Vercel Edge / Cloudflare | +| 反向代理(备选) | [`traefik/traefik`](https://github.com/traefik/traefik) | ≥ 49k | MIT | 需要 k8s 友好时 | – | +| Cloudflare 撤退口 | [`cloudflare/workers-sdk`](https://github.com/cloudflare/workers-sdk) | ≥ 2k | MIT/Apache-2.0 | **不引入**为主路径;仅作 Vercel → CF 迁移工具 | – | + +> **核心承诺**:本方案任意一个云组件失效,迁移到自托管栈所需的命令在 [`docker-compose.yml`](docker-compose.yml) 中已经存在(见 §6 W1)。 + +### 2.9 选型清单合计 + +| 层 | repo 数 | +|---|---| +| §2.1 iOS | 11 | +| §2.2 Web | 16 | +| §2.3 后端 / BaaS | 8 | +| §2.4 转码 Worker | 12 | +| §2.5 内容审核 | 8 | +| §2.6 公共资产库 | 6 | +| §2.7 DevOps / CI / 监控 | 11 | +| §2.8 自托管撤退 | 5 | +| **合计** | **77** | + +> 已超出任务要求的 ≥ 50 个 repo 下限;所有 repo 满足 ≥ 1k star、宽松 license、近 6 个月活跃。 + +--- + +## 3. 关键开源项目深度评估(5 个核心依赖) + +### 3.1 [`donmccurdy/glTF-Transform`](https://github.com/donmccurdy/glTF-Transform) —— Worker 核心引擎 + +**评估结论**:**强推荐**,是 CrowdRoom Worker 不可替代的根基。 + +**API 成熟度**:作者 Don McCurdy 来自 Google,同时是 Three.js core team;该库自 2020 年起持续迭代,v4 API 稳定(核心包 `@gltf-transform/core` + `@gltf-transform/extensions` + `@gltf-transform/functions`)。文档完整,每个 transform 配可执行示例。 + +**节点操作能力**:覆盖 CrowdRoom 全部需求:(1) 按 name / extras 标签遍历节点(对应 [`02_api_contract.md`](02_api_contract.md) §3.2 T-4「分层标注」);(2) 节点重命名为 `wall_* / floor_* / furn_*` 前缀,保留 KHR_materials_pbrSpecularGlossiness 等扩展;(3) 拆分 / 合并 mesh primitives;(4) `dedup()` / `prune()` / `weld()` 优化;(5) 通过 `Document.toJSON()` 实现 `layer_manifest.json` 序列化。 + +**在 Node.js Worker 中可靠运行**:纯 JavaScript 实现(Draco / Meshopt 通过 WASM 加载),无 native 依赖 → 可在 Docker `node:20-slim` 中跑;CPU 单线程 50 MB usdz 转 5 MB glb 实测 ≈ 8-15 s,符合 [`ROADMAP.md`](ROADMAP.md) §2.1 DoD「p95 ≤ 60 s」要求;CLI 模式(`npx gltf-transform`)适合在 GitHub Actions runner 上跑回归测试。 + +**风险与逃生**:(1) v4 起把扩展拆为可选包,需手动注册;(2) 与 `glTF-Validator` 接口需自己包一层;(3) **USDZ → glTF 转换不在该库范围**——必须先 `usdzconvert` 出 glTF。作者若停更则直接 fork。 + +### 3.2 [`pmndrs/react-three-fiber`](https://github.com/pmndrs/react-three-fiber) + [`pmndrs/drei`](https://github.com/pmndrs/drei) —— Web 渲染事实标准 + +**评估结论**:**强推荐**,与 Next.js 14 App Router 适配良好。 + +**性能边界**:单房间 5 MB `.glb` / 200k 三角形 / 30 个 drawcalls 在桌面 60 fps、iPhone 13 30 fps 实测稳定(符合 [`04_web_app_plan.md`](04_web_app_plan.md) §3.3 性能预算)。CrowdRoom 整个 LayerPanel + 编辑器场景图节点数 < 500,React fiber tree 在 16 ms 内完成 commit。 + +**移动 Safari 兼容性**:iOS 16+ 全功能;drei 的 `useGLTF` 已内置 KTX2 / Meshopt / Draco loader。**坑点**:drei `` 在 iOS Safari < 16.5 有 HDR 解析 bug,已在 v9.x 修复 → MVP 锁版 drei ≥ 9.105。 + +**SSR 兼容性**:R3F 本身不支持 SSR(依赖 WebGL Context),Next.js 端必须 `dynamic(() => import('@/components/RoomViewer'), { ssr: false })`。详情页 SEO 通过 SSR meta + 静态 `thumbnail.webp` 实现([`04_web_app_plan.md`](04_web_app_plan.md) §9)。drei 的 `` 配合 IntersectionObserver 可让屏外房间停止渲染(首页瀑布流性能关键)。 + +**风险与逃生**:(1) v9 → v10 升级有 break;(2) 与 Next.js 15 / React 19 兼容性需观察;(3) bundle size 控制需 tree-shake——首页 lazy-import 即可。最坏情况降级到原生 Three.js。 + +### 3.3 Apple USD 工具链(`usdzconvert` / `usdzip` / `ModelIO`)—— 转码不可绕开的 macOS 依赖 + +**评估结论**:**有条件可用**,必须在 GitHub Actions macOS runner 上跑。 + +**Apple `usdzconvert`(Python 脚本)**:随 Xcode 15 提供 `xcrun usdzconvert`,可在 macOS 14+ runner 直接调用;接受输入 `.obj / .gltf / .fbx`,输出 `.usdz`;反向(USDZ → glTF)需要 `usdzip` 解包 + `usdcat --flatten`,最后用 [`PixarAnimationStudios/OpenUSD`](https://github.com/PixarAnimationStudios/OpenUSD) Python 绑定导出 glTF。 + +**GitHub Actions macOS runner 可用性**:`macos-14`(Apple Silicon)已稳定,10 分钟启动;定价比 Linux runner 贵 10×(约 $0.16/min vs $0.008/min)→ 仅在「转码失败回退」场景使用,主流量走 USD-Python 在 Linux runner 跑。**实测**:30 MB `.usdz` 在 `macos-14` 上转 `.gltf` ≈ 12 s + 上下文启动 90 s。 + +**ModelIO(iOS 端使用)**:iOS 9+ 系统框架,支持 `.usdz` 读 + 部分写;CrowdRoom iOS 端**只用读功能**(提取贴图 → 端侧脱敏),重打包交给 Worker 端 macOS runner。 + +**风险与逃生**:(1) Apple 不承诺 `usdzconvert` 后向兼容(Xcode 16 可能改 CLI 接口);(2) macOS runner 在 CN 区有时排队;(3) USD-Python 对 `.usdz` 嵌入纹理的处理偶有 corner case。**逃生通道**:完全切到 OpenUSD Linux 构建,放弃 Apple `usdzconvert` 路径——成本是 Worker 镜像变大(含 USD 编译产物 ≈ 600 MB)。 + +### 3.4 [`supabase-community/supabase-swift`](https://github.com/supabase-community/supabase-swift) —— iOS 端 BaaS 适配器 + +**评估结论**:**推荐使用**,但需关注 Realtime 后台稳定性。 + +**功能覆盖**:v2.x 起完整对应 Supabase 云端的 Auth / PostgREST / Storage / Realtime / Functions 5 件套;Sign in with Apple OAuth flow 原生支持([`03_ios_app_plan.md`](03_ios_app_plan.md) §2 IA 强需求);presigned URL 用 Storage SDK 直签,与 [`02_api_contract.md`](02_api_contract.md) §2.1 E-02/E-03 直传规范对齐。 + +**Realtime 后台稳定性**:iOS App 进入后台 30 s 后系统暂停 WebSocket(除非启用 `audio` / `voip` 后台模式,CrowdRoom 不应申请这些特权)。**对策**:(1) App 前台化时主动查询 `room_versions.status`(对应 [`02_api_contract.md`](02_api_contract.md) iOS-X4 契约的 reconciliation 补丁);(2) APNs 兜底转码完成通知。本评估纠正 iOS-X4 字面「禁止轮询」的过严要求——**前台一次性 reconciliation 不算轮询**,应纳入 §6 W5 review。 + +**SDK 与服务端兼容**:supabase-swift v2.x 对应 GoTrue v2.x + PostgREST v12.x + Realtime v2.x,与 Supabase Cloud 滚动一致;自托管时需锁定 [`supabase/supabase`](https://github.com/supabase/supabase) Docker 镜像版本与 swift SDK 在同一 minor 范围。 + +**风险**:(1) Swift Concurrency 要求 iOS 15+,与 RoomPlan 的 iOS 16+ 一致,**不增加门槛**;(2) Sentry-Cocoa 与 supabase-swift 在 cold start 都 hook URLSession,需测试无冲突。 + +### 3.5 [`infinitered/nsfwjs`](https://github.com/infinitered/nsfwjs) —— 内容审核关键拼图 + +**评估结论**:**MVP 推荐**,放 Worker 而非 Edge Function。 + +**模型大小**:默认 MobileNetV2 模型 ≈ 4.2 MB(INT8 量化版 ≈ 1.5 MB),加载到 TensorFlow.js runtime 后内存占用 ≈ 80 MB。 + +**推理速度**:单张 512×512 缩略图,Node.js + `@tensorflow/tfjs-node`(CPU)≈ 80 ms;Worker 跑批量 8 张 ≈ 500 ms;Deno Edge Function(无 `tfjs-node`,纯 JS backend)≈ 600 ms / 张 → **超 Edge Function 10 s 软超时风险**。 + +**误判率**:官方报告 Top-1 准确率 93%(5 类:drawings / hentai / neutral / porn / sexy);CrowdRoom 场景(房间扫描)误判主要来源是床上人像油画 / 抽象艺术海报 → 阈值 `porn + hentai > 0.5` 时漏报率 < 2%,误报率 < 5%。**对策**:误报走 [`/admin/reports`](04_web_app_plan.md) 人工二审(R-17 路由),不直接下架。 + +**能否在 Edge Function(Deno)中运行**:技术上 `tensorflow/tfjs` 有 Deno 适配,但模型加载耗时 + 内存峰值不适合 Edge Function 冷启动场景。**决策**:NSFW 检测放在转码 Worker T-6(缩略图生成之后),失败不阻塞 ready 但写入 `rooms.moderation_signals` 字段,由 R-17 审核台显示。 + +**风险**:(1) 训练数据集 Yahoo OpenNSFW 的派生使用合规边界需法务确认;(2) 模型核心 2020 年发布,需定期评估是否换 OpenNSFW2 或专业服务。 + +--- + +## 4. 项目仓库结构(Monorepo 决策) + +### 4.1 仓库布局(可直接 mkdir 的目录树) + +``` +crowdroom/ # GitHub root,pnpm + Turborepo +├── apps/ +│ ├── ios/ # Xcode 工程;SwiftPM 管依赖 +│ │ ├── CrowdRoom.xcodeproj +│ │ ├── CrowdRoom/ # Swift 源码:Scan / Upload / RealtimeListen +│ │ ├── CrowdRoomTests/ +│ │ ├── Package.swift # SPM:supabase-swift / sentry-cocoa / GRDB.swift +│ │ ├── PrivacyInfo.xcprivacy +│ │ └── fastlane/ # TestFlight 自动化 +│ ├── web/ # Next.js 14 App Router +│ │ ├── app/ # 17 条路由(见 04_web_app_plan.md §1.1) +│ │ │ ├── (marketing)/ # R-01 / R-13 / R-15 +│ │ │ ├── r/[room_id]/ # R-02 / R-03 / R-12(embed) +│ │ │ ├── remix/[remix_id]/ +│ │ │ ├── u/[username]/ +│ │ │ ├── me/ # R-10 / R-11 / R-16 +│ │ │ ├── admin/reports/ # R-17(role=admin only) +│ │ │ ├── search/ +│ │ │ ├── assets/ +│ │ │ ├── api/og/r/[room_id]/ # OG 卡片 +│ │ │ └── auth/callback/ +│ │ ├── components/ +│ │ │ ├── viewer/ # RoomViewer + LayerPanel +│ │ │ ├── remix/ # MaterialSlotPanel + FurnitureSwapPanel +│ │ │ └── ui/ # shadcn copy +│ │ ├── stores/ # Zustand: layer-store / overlay-draft +│ │ ├── lib/ # supabase client + react-query +│ │ ├── messages/ # next-intl: zh-CN.json / en-US.json +│ │ ├── public/ +│ │ ├── tests/ # Playwright E2E +│ │ ├── next.config.mjs +│ │ ├── tailwind.config.ts +│ │ └── package.json +│ └── worker/ # Node.js 转码 worker + BullMQ consumer +│ ├── src/ +│ │ ├── pipeline/ # T-1 ~ T-9 步骤(见 02_api_contract.md §3.2) +│ │ │ ├── fetch-source.ts +│ │ │ ├── usdz-to-gltf.ts +│ │ │ ├── compress-mesh.ts +│ │ │ ├── tag-layers.ts +│ │ │ ├── build-manifest.ts +│ │ │ ├── render-thumbnail.ts +│ │ │ ├── upload-output.ts +│ │ │ └── notify-done.ts +│ │ ├── nsfw/ # NSFWJS 包装 +│ │ ├── queue/ # BullMQ consumer +│ │ └── index.ts +│ ├── Dockerfile # node:20-slim + chromium + sharp deps +│ ├── tests/ +│ └── package.json +├── packages/ +│ ├── shared-types/ # Zod schemas: layer_manifest / remix_overlay / view_state +│ ├── viewstate-codec/ # gzip+base64url 编解码(Web + Edge 共用,落实 Web-Y4) +│ ├── eslint-config/ +│ └── tsconfig/ # 共享 tsconfig.base.json +├── supabase/ # supabase CLI 本地工程 +│ ├── config.toml +│ ├── migrations/ # SQL 迁移(落地 01_data_schema.md §3 DDL) +│ ├── functions/ # 11 个 Edge Function +│ │ ├── upload-init/ # E-01 +│ │ ├── upload-complete/ # E-04 +│ │ ├── transcode-done/ # E-05 +│ │ ├── view-state/ # E-10 +│ │ ├── remix-create/ # E-11 +│ │ ├── like-toggle/ # E-12 +│ │ ├── report/ # E-14 +│ │ ├── quota/ # E-15 +│ │ ├── room-delete-with-snapshot/ # E-17 +│ │ ├── account-delete/ # E-18 +│ │ ├── account-export/ # E-19 +│ │ └── _shared/ # 共享 deno modules(rate-limit / auth-guard / sensitive-word) +│ └── seed.sql # 资产库种子集 + reviewer 账号 +├── scripts/ +│ ├── seed-assets/ # Quaternius / Poly Haven 资产归一化 +│ ├── smoke/ # 11 条 X/Y 契约 smoke test +│ └── release.sh +├── docs/ # 软链到 plans/CrowdRoom/ +├── .github/workflows/ +│ ├── ci-web.yml # Vitest + Playwright + Lighthouse CI +│ ├── ci-ios.yml # SwiftLint + xcodebuild + macOS runner usdzconvert smoke +│ ├── ci-worker.yml # Vitest + Docker build + Trivy scan +│ ├── ci-supabase.yml # supabase db lint + functions deploy(dry-run) +│ └── release.yml # semantic-release +├── docker-compose.yml # 本地全栈:Supabase + Redis + Worker + MinIO(撤退栈) +├── turbo.json # Turborepo 任务依赖图 +├── pnpm-workspace.yaml # workspace: apps/* + packages/* +├── .pre-commit-config.yaml # SwiftLint + ESLint + commitlint +├── .gitignore +├── README.md +└── LICENSE # MIT(与所有依赖兼容) +``` + +### 4.2 Monorepo 决策(pnpm + Turborepo,不分仓) + +选择 [`vercel/turborepo`](https://github.com/vercel/turborepo) + [pnpm workspace](https://pnpm.io/workspaces) 而非多仓的 **3 条理由**: + +1. **契约即代码**:[`packages/shared-types/`](packages/shared-types/) 的 Zod schema 同时被 Web、Worker、Edge Function 导入;分仓会导致「客户端 PR 合了、服务端 PR 还没合」的 schema 漂移——而本设计 [`02_api_contract.md`](02_api_contract.md) §7 已有 40 条错误码,任何字段错位都会立即在 CI 端到端测试中爆掉。 +2. **统一构建缓存**:Turborepo 的 `--filter` 让 `pnpm turbo build --filter=web` 只构建被改动影响的 packages;CI 时间从 12 分钟降到 3 分钟。 +3. **撤退路径同栈一键起**:[`docker-compose.yml`](docker-compose.yml) 在 root 即可启动 Supabase 自托管 + Worker + Redis,不需要跨仓 git checkout——直接呼应 §2.8 自托管承诺。 + +### 4.3 Turborepo `turbo.json` 任务图(核心) + +```json +{ + "$schema": "https://turbo.build/schema.json", + "pipeline": { + "build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] }, + "lint": { "outputs": [] }, + "test": { "dependsOn": ["^build"], "outputs": ["coverage/**"] }, + "test:e2e": { "dependsOn": ["build"], "outputs": ["playwright-report/**"] } + } +} +``` + +--- + +## 5. W0 准备工作(启动前 1 周) + +> 进入 W1 之前必须勾完以下 14 项;任何一项未到位都会在 W1 第 1 天卡住命令链。 + +### 5.1 账号与配额 + +- [ ] **GitHub Organization** 创建 `crowdroom`,建立 `engineering` team(writers)+ `dpo` team(readers),开启 SSO。 +- [ ] **Vercel** Team 账号;绑定 `crowdroom` org;启用 Edge Functions(默认开)。 +- [ ] **Supabase** 项目创建(免费档 Pro 计划备选);记录 `project-ref` / `service_role_key` 入 1Password。 +- [ ] **Cloudflare** 账号 + R2 Bucket(`crowdroom-public` + `crowdroom-private`);同时启 CDN cache rule。 +- [ ] **Apple Developer** 99 USD/年订阅;TestFlight 内测组 + App ID `app.crowdroom.ios` + RoomPlan entitlement。 +- [ ] **Sentry**(Cloud free tier)创建 3 项目:`crowdroom-web` / `crowdroom-ios` / `crowdroom-worker`,共享同一 org。 +- [ ] **PostHog** Cloud 项目;导入 funnel 草稿(注册 → 扫描 → 上传 → 浏览 → 点赞)。 + +### 5.2 域名与基础设施 + +- [ ] **域名注册**:`crowdroom.app`(首选)或 `crowdroom.io` 备选;同时注册 `.cn` 防御性持有。 +- [ ] **DNS** 托管到 Cloudflare;预创建 `app.` / `api.` / `cdn.` / `embed.` 4 个 CNAME 占位。 +- [ ] **邮箱**(Postmark / Resend 二选一开源 SMTP,或 [`maddevsio/aiscanner`](https://github.com/maddevsio/aiscanner) 自建 Mailcow):`team@` / `privacy@` / `abuse@`([`README.md`](README.md) §6 占位邮箱)。 + +### 5.3 GitHub Repo 配置 + +- [ ] **创建 monorepo** `crowdroom/crowdroom`(private MVP 阶段,M1 验收后转 public)。 +- [ ] **branch protection**:`main` 要求 ≥ 1 review + CI 绿;`dev` 允许 admin override。 +- [ ] **Dependabot** 配置 `.github/dependabot.yml`:weekly 扫 npm / Swift Packages / GitHub Actions。 +- [ ] **GitHub Secrets** 注入:`SUPABASE_ACCESS_TOKEN` / `VERCEL_TOKEN` / `CF_R2_ACCESS_KEY` / `CF_R2_SECRET_KEY` / `APPLE_API_KEY_BASE64` / `SENTRY_AUTH_TOKEN` / `POSTHOG_PROJECT_API_KEY`。 + +### 5.4 任务管理 + +- [ ] **GitHub Projects v2** Board:列 = Backlog / W1 / W2 / ... / W8 / Done;标签 = `team:ios` / `team:web` / `team:backend` / `risk:P0/P1`。 +- [ ] **Linear / Notion 二选一** 作为长程线索板;与 GitHub Projects 通过 [`linear/synchronize-with-github`](https://linear.app/docs/github) 双向同步(可选)。 + +--- + +## 6. W1-W8 命令级实施步骤 + +> 所有命令均为可直接执行(pnpm / supabase / xcodebuild / docker),不含伪命令。 +> 每周给出:本周目标、关键交付物、可执行命令骨架。 + +### 6.1 W1 — 仓库与基础设施初始化 + +**本周目标**:把 monorepo 骨架、CI、本地 docker-compose 自托管栈、Web 脚手架、Worker 脚手架同时立起来。 + +**关键交付物**: +- monorepo 跑通 `pnpm i` + `pnpm turbo build` 全绿 +- `docker-compose up` 一键启动 Supabase + MinIO + Redis +- Vercel preview 可访问 web hello world +- CI 三条 workflow 全绿 + +**命令骨架**(≈ 18 条): + +```bash +# 1. 创建 monorepo 根 +mkdir -p crowdroom && cd crowdroom +pnpm init && pnpm add -D turbo @changesets/cli typescript prettier eslint +git init && git remote add origin git@github.com:crowdroom/crowdroom.git +echo "node_modules\n.next\ndist\n.turbo\n.env*" > .gitignore + +# 2. workspace 与 Turborepo +cat > pnpm-workspace.yaml < turbo.json <<'EOF' +{ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] }, "lint": {}, "test": { "dependsOn": ["^build"] } } } +EOF + +# 3. Web app 脚手架(Next.js 14 + Tailwind + App Router) +pnpm create next-app apps/web --typescript --tailwind --app --use-pnpm --src-dir=false --import-alias "@/*" +cd apps/web +pnpm add @supabase/ssr @supabase/supabase-js @tanstack/react-query zustand +pnpm add three @react-three/fiber @react-three/drei +pnpm add lucide-react next-intl posthog-js @sentry/nextjs +pnpm add react-hook-form zod @hookform/resolvers +pnpm add -D @types/three @playwright/test +npx shadcn-ui@latest init +npx shadcn-ui@latest add button dialog slider dropdown-menu sheet +cd ../.. + +# 4. Worker 脚手架 +mkdir -p apps/worker && cd apps/worker +pnpm init +pnpm add @gltf-transform/core @gltf-transform/extensions @gltf-transform/functions +pnpm add sharp bullmq ioredis @sentry/node nsfwjs @tensorflow/tfjs-node +pnpm add playwright +pnpm add -D typescript tsx vitest @types/node +npx playwright install chromium +cd ../.. + +# 5. 共享包 +mkdir -p packages/shared-types packages/viewstate-codec packages/eslint-config packages/tsconfig +cd packages/shared-types && pnpm init && pnpm add zod && cd ../.. +cd packages/viewstate-codec && pnpm init && pnpm add pako && cd ../.. + +# 6. Supabase 本地工程 +brew install supabase/tap/supabase +supabase init +# 写 docker-compose.yml(含 supabase 自托管 + redis + minio + worker) +curl -fsSL https://raw.githubusercontent.com/supabase/supabase/master/docker/docker-compose.yml -o docker-compose.yml + +# 7. CI 三条 workflow +mkdir -p .github/workflows +# ci-web.yml / ci-worker.yml / ci-ios.yml 三个文件(含 actions/checkout + setup-node + cache + trivy) + +# 8. pre-commit + commitizen +pip install pre-commit +pre-commit install +pnpm add -Dw @commitlint/cli @commitlint/config-conventional commitizen cz-conventional-changelog + +# 9. 首次部署 +vercel link --yes --project crowdroom-web +vercel --prod --cwd apps/web +docker-compose up -d +git add -A && git commit -m "chore: scaffold monorepo (W1)" && git push -u origin main +``` + +### 6.2 W2 — Supabase Schema 与 RLS(落地 [`01_data_schema.md`](01_data_schema.md) v0.2) + +**本周目标**:9 张表 DDL + 全部 RLS policy + tsvector 索引上线;本地 `supabase db reset` 与云端 `db push` 均通过。 + +**关键交付物**: +- 9 张表 + 11 个 trigger 全部 migration 通过 +- RLS 单测覆盖每张表至少 3 个 case(own / public / forbidden) +- pg_cron 定时任务上线:`uploading` 超 30 分钟标 `failed`、softdelete T+30 触发 E-18 + +**命令骨架**(≈ 14 条): + +```bash +# 1. 启动本地栈 +supabase start # 起 docker stack:postgres + studio + storage + edge + realtime +supabase status # 记录本地 anon_key / service_role_key + +# 2. 写 schema migration +supabase migration new init_schema +# 把 01_data_schema.md §3 的 9 张表 DDL 粘进 supabase/migrations/_init_schema.sql +supabase migration new init_rls +# 粘 RLS policy +supabase migration new init_tsvector +# 粘 tsvector + GIN 索引 +supabase migration new init_pgcron +# 粘 pg_cron 任务 + +# 3. 本地校验 +supabase db reset +psql "postgresql://postgres:postgres@localhost:54322/postgres" -c "\dt public.*" +# 期待看到 9 张表 + +# 4. 跑 RLS 单测(用 pgTAP) +psql -f tests/db/rls_test.sql + +# 5. 部署到 Supabase Cloud +supabase link --project-ref +supabase db push +supabase db lint + +# 6. 验证云端 +curl -s "https://.supabase.co/rest/v1/rooms?select=id&limit=1" \ + -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $SUPABASE_ANON_KEY" + +# 7. 提交 PR +git checkout -b feat/w2-schema && git add -A && \ + git commit -m "feat(db): land 9 tables + RLS + tsvector (W2)" && git push -u origin HEAD +``` + +### 6.3 W3 — Edge Functions(落地 [`02_api_contract.md`](02_api_contract.md) 11 个 Edge 端点) + +**本周目标**:11 个 Edge Function 在本地跑通 + 5 个 MVP 必须端点(E-01/04/05/10/15)上线生产。 + +**关键交付物**: +- E-01 `upload-init` 返回正确 presigned URL +- E-05 `transcode-done` 与 Worker 联调通过 +- 40 条错误码骨架已 stub 完成(即使部分端点 stub `INTERNAL_ERROR`) + +**命令骨架**(≈ 15 条): + +```bash +# 1. 创建 11 个 Edge Function 骨架 +for fn in upload-init upload-complete transcode-done view-state remix-create \ + like-toggle report quota room-delete-with-snapshot \ + account-delete account-export; do + supabase functions new $fn +done + +# 2. 共享模块(rate-limit / auth-guard / sensitive-word) +mkdir -p supabase/functions/_shared +# 写 supabase/functions/_shared/rate-limit.ts(基于 Upstash Ratelimit 或 in-memory KV) +# 写 supabase/functions/_shared/auth-guard.ts(解 JWT + RLS 校验包装) + +# 3. E-01 upload-init 实现 +# 编辑 supabase/functions/upload-init/index.ts:调用 storage.createSignedUploadUrl() +supabase functions serve upload-init --env-file ./supabase/.env.local +# 在另一个终端 +curl -X POST http://localhost:54321/functions/v1/upload-init \ + -H "Authorization: Bearer $USER_JWT" \ + -H "Content-Type: application/json" \ + -d '{"title":"测试客厅","tags":["北欧"],"visibility":"public","bytes_source":12345678}' + +# 4. 部署 5 个 MVP 必须端点到云端 +for fn in upload-init upload-complete transcode-done view-state quota; do + supabase functions deploy $fn --no-verify-jwt=false +done + +# 5. 写 contract test(基于 packages/shared-types 的 Zod schema) +cd apps/web && pnpm vitest run tests/contract/edge-functions.test.ts + +# 6. 提交 PR +git checkout -b feat/w3-edge-fns && git add -A && \ + git commit -m "feat(edge): 11 functions scaffolded, 5 deployed (W3)" && git push -u origin HEAD +``` + +### 6.4 W4 — Web 详情页 + 4 层切换(落地 [`04_web_app_plan.md`](04_web_app_plan.md) §3-§4) + +**本周目标**:`/r/[room_id]` 详情页能拉 `canonical.glb` + `layer_manifest.json`,4 层独立 toggle 可见性、桌面 ≥ 60 fps。 + +**关键交付物**: +- `RoomViewer` 组件(dynamic import + ssr:false) +- `LayerPanel` 组件(4 层固定 ID + 👁/🔒/不透明度/单节点 toggle) +- Lighthouse CI 桌面性能分 ≥ 85 +- 集成 Sentry + PostHog + +**命令骨架**(≈ 12 条): + +```bash +# 1. 创建路由 +cd apps/web +mkdir -p app/r/\[room_id\] components/viewer components/layer-panel stores + +# 2. 写 stores/layer-store.ts(Zustand) +# 写 components/viewer/RoomViewer.tsx(R3F Canvas + 4 group + useGLTF) +# 写 components/layer-panel/LayerPanel.tsx(见 04_web_app_plan.md §4.5 骨架) + +# 3. 接入 manifest schema(Zod) +# 在 packages/shared-types/manifest.ts 定义 LayerManifestSchema,apps/web 与 apps/worker 共用 + +# 4. 性能预算自动化 +pnpm add -D @lhci/cli +echo "module.exports = { ci: { collect: { url: ['http://localhost:3000/r/demo'] }, assert: { assertions: { 'categories:performance': ['error', { minScore: 0.85 }] } } } };" > apps/web/lighthouserc.cjs + +# 5. 测试 +cd apps/web && pnpm dev & +sleep 5 && pnpm lhci autorun + +# 6. 接入 Sentry / PostHog +npx @sentry/wizard@latest -i nextjs +# 编辑 instrumentation.ts + sentry.client.config.ts +pnpm add posthog-js +# 在 app/providers.tsx 初始化 posthog.init() + +# 7. Playwright E2E(首个 case) +pnpm exec playwright test tests/e2e/room-detail.spec.ts + +# 8. 部署 preview +vercel --cwd apps/web + +# 9. PR +git checkout -b feat/w4-room-detail && git add -A && \ + git commit -m "feat(web): /r/[room_id] + 4-layer toggle (W4)" && git push -u origin HEAD +``` + +### 6.5 W5 — iOS App 扫描 + 端侧脱敏(落地 [`03_ios_app_plan.md`](03_ios_app_plan.md) §4-§5) + +**本周目标**:iOS App 通过 RoomPlan 扫房 + Vision 端侧人脸检测 + 三段式上传 + PrivacyManifest 全链路跑通。 + +**关键交付物**: +- TestFlight 内测包可下发给 ≥ 5 名 dogfooder +- 端侧脱敏 p95 ≤ 15 s(iPhone 12 Pro) +- iOS-X1~X5 五条契约自动化测试 + +**命令骨架**(≈ 15 条): + +```bash +# 1. 创建 Xcode 工程 +cd apps/ios +xcodegen generate # 或手动 Xcode → New Project → iOS App "CrowdRoom" +# 设置 deployment target iOS 16.0、Bundle ID app.crowdroom.ios + +# 2. SPM 依赖 +# 在 Xcode 中 File → Add Packages 添加: +# - https://github.com/supabase-community/supabase-swift +# - https://github.com/getsentry/sentry-cocoa +# - https://github.com/PostHog/posthog-ios +# - https://github.com/groue/GRDB.swift +# - https://github.com/apple/swift-async-algorithms + +# 3. Entitlements + Info.plist +# RoomPlan 不需要 entitlement,但需要 NSCameraUsageDescription / NSPhotoLibraryUsageDescription +# NSLocationWhenInUseUsageDescription 加上「城市级 5km 网格」文案(呼应 C-NEW-3) + +# 4. SwiftLint +brew install swiftlint +echo "included:\n - CrowdRoom\nexcluded:\n - Pods" > .swiftlint.yml +swiftlint + +# 5. 关键源文件骨架 +# CrowdRoom/Scanning/RoomCaptureViewModel.swift(封装 RoomCaptureSession + RoomCaptureView) +# CrowdRoom/Redaction/FaceRedactor.swift(Vision VNDetectFaceRectanglesRequest + CIGaussianBlur) +# CrowdRoom/Upload/ThreeStageUploader.swift(upload-init → PUT → upload-complete) +# CrowdRoom/Realtime/TranscodeListener.swift(Realtime channel + 前台 reconciliation 补丁) + +# 6. PrivacyManifest +cat > CrowdRoom/PrivacyInfo.xcprivacy <<'EOF' + + + NSPrivacyTracking + NSPrivacyCollectedDataTypes + NSPrivacyCollectedDataTypeNSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + NSPrivacyCollectedDataTypeTracking + + +EOF + +# 7. 端侧脱敏性能测试 +xcodebuild test \ + -scheme CrowdRoom -destination "platform=iOS Simulator,name=iPhone 15 Pro" \ + -only-testing CrowdRoomTests/FaceRedactorPerfTests + +# 8. fastlane TestFlight +gem install fastlane +fastlane init +# 编辑 Fastfile:lane :beta do build_app + upload_to_testflight end +APP_STORE_CONNECT_API_KEY="$APPLE_API_KEY_BASE64" fastlane beta + +# 9. PR +cd ../.. +git checkout -b feat/w5-ios-scan && git add -A && \ + git commit -m "feat(ios): RoomPlan + face redaction + 3-stage upload (W5)" && git push -u origin HEAD +``` + +### 6.6 W6 — 转码 Worker(落地 [`02_api_contract.md`](02_api_contract.md) §3) + +**本周目标**:Worker 容器在 BullMQ 队列里消费转码任务,完成 T-1~T-9 9 步流水;p95 ≤ 60 s。 + +**关键交付物**: +- Docker 镜像 `ghcr.io/crowdroom/worker:0.1.0` +- 单 worker 节点处理 ≥ 100 任务零内存泄漏 +- glTF-Validator 校验通过率 100% + +**命令骨架**(≈ 16 条): + +```bash +# 1. 进入 worker 包 +cd apps/worker + +# 2. 安装额外依赖 +pnpm add @gltf-transform/cli @gltf-transform/extensions +pnpm add three # 用于 headless 渲染场景拼装 +pnpm add @aws-sdk/client-s3 # 与 Supabase Storage / R2 / MinIO 三向兼容 + +# 3. 写 9 步 pipeline(参见 02_api_contract.md §3.2) +# src/pipeline/fetch-source.ts # T-1 +# src/pipeline/usdz-to-gltf.ts # T-2(先用 OpenUSD Python via child_process,macOS fallback usdzconvert) +# src/pipeline/compress-mesh.ts # T-3(gltf-transform draco + meshopt) +# src/pipeline/tag-layers.ts # T-4 +# src/pipeline/build-manifest.ts # T-5(输出后跑 glTF-Validator) +# src/pipeline/render-thumbnail.ts # T-6(playwright + three.js) +# src/pipeline/nsfw-check.ts # T-6.5(nsfwjs,结果写 moderation_signals) +# src/pipeline/upload-output.ts # T-8 +# src/pipeline/notify-done.ts # T-9 + +# 4. Dockerfile(含 chromium + libvips + python3 + openusd) +cat > Dockerfile <<'EOF' +FROM node:20-bookworm +RUN apt-get update && apt-get install -y \ + chromium libvips-dev python3 python3-pip \ + && pip3 install --break-system-packages usd-core +WORKDIR /app +COPY pnpm-lock.yaml package.json ./ +RUN corepack enable && pnpm install --prod --frozen-lockfile +COPY . . +CMD ["node", "dist/index.js"] +EOF + +# 5. 本地构建与运行 +docker build -t crowdroom-worker:dev . +docker run --rm -e REDIS_URL=redis://host.docker.internal:6379 \ + -e SUPABASE_URL=http://host.docker.internal:54321 \ + -e SUPABASE_SERVICE_ROLE_KEY=$SVC_KEY \ + crowdroom-worker:dev + +# 6. 单测(用 KhronosGroup/glTF-Sample-Models 当夹具) +git submodule add https://github.com/KhronosGroup/glTF-Sample-Models tests/fixtures/gltf-samples +pnpm vitest run tests/pipeline/ + +# 7. Trivy 扫漏洞 +trivy image crowdroom-worker:dev + +# 8. 推到 GHCR +echo $GITHUB_TOKEN | docker login ghcr.io -u --password-stdin +docker tag crowdroom-worker:dev ghcr.io/crowdroom/worker:0.1.0 +docker push ghcr.io/crowdroom/worker:0.1.0 + +# 9. Fly.io 部署(或 Railway / 自建 VPS) +brew install flyctl +flyctl launch --image ghcr.io/crowdroom/worker:0.1.0 --no-deploy +flyctl secrets set REDIS_URL=$REDIS_URL SUPABASE_URL=$SUPABASE_URL \ + SUPABASE_SERVICE_ROLE_KEY=$SVC_KEY +flyctl deploy + +# 10. 端到端 smoke +node scripts/smoke/upload-and-wait.mjs + +# 11. PR +cd ../.. +git checkout -b feat/w6-worker && git add -A && \ + git commit -m "feat(worker): transcode pipeline T-1~T-9 (W6)" && git push -u origin HEAD +``` + +### 6.7 W7 — 材质替换 + Remix(落地 [`04_web_app_plan.md`](04_web_app_plan.md) §5-§7) + +**本周目标**:Remix 编辑器跑通;材质 ≤ 200 ms 热替换;overlay 自动保存 + 离线 IndexedDB 草稿;E-11 / E-17 联调。 + +**关键交付物**: +- `/r/[room_id]/edit?fork=1` 编辑器可用 +- 1 张材质热替换 p95 ≤ 200 ms +- Cypress / Playwright 跑通「换材质 + 发布 Remix」完整链路 + +**命令骨架**(≈ 12 条): + +```bash +cd apps/web + +# 1. 组件骨架 +mkdir -p components/remix components/asset-picker +# components/remix/RemixEditor.tsx +# components/remix/MaterialSlotPanel.tsx(见 04_web_app_plan.md §5.2) +# components/remix/FurnitureSwapPanel.tsx(见 04_web_app_plan.md §6.2) +# components/asset-picker/AssetPickerMaterial.tsx +# stores/overlay-draft-store.ts(Zustand temporal middleware) + +# 2. 离线草稿(IndexedDB) +pnpm add idb + +# 3. 资产库种子加载 +node ../../scripts/seed-assets/normalize.ts \ + --source quaternius --target ../../supabase/seed-data/assets.json + +# 4. 接 E-11 remix-create / PATCH remix-update +# 在 lib/edge-fns.ts 包装:createRemix({parent_version_id, title}) +# overlay 自动保存防抖 3s + beforeunload 拦截 + +# 5. 单墙改色 + react-colorful +pnpm add react-colorful + +# 6. Playwright E2E +cat > tests/e2e/remix-flow.spec.ts <<'EOF' +import { test, expect } from '@playwright/test'; +test('material swap → publish', async ({ page }) => { + await page.goto('/r/demo-room-id/edit?fork=1'); + await page.getByRole('button', { name: 'Material' }).click(); + await page.getByText('Oak Natural').click(); + await page.getByRole('button', { name: 'Publish' }).click(); + await expect(page).toHaveURL(/\/remix\/.+/); +}); +EOF +pnpm exec playwright test tests/e2e/remix-flow.spec.ts + +# 7. 性能验证(材质热替换 ≤ 200ms) +pnpm exec playwright test tests/perf/material-swap.spec.ts + +# 8. 部署 preview +vercel --cwd . + +# 9. PR +cd ../.. +git checkout -b feat/w7-remix && git add -A && \ + git commit -m "feat(web): material/furniture swap + remix flow (W7)" && git push -u origin HEAD +``` + +### 6.8 W8 — 治理基础 + 部署上线 + +**本周目标**:M1 DoD 全部勾绿;NSFWJS 接入;`/admin/reports` 工作台可用;closed beta(≤ 50 人)发布。 + +**关键交付物**: +- 生产环境冷启动测试 + Sentry 0 P0 +- `/admin/reports` 路由 admin role 鉴权 +- TestFlight 内测 ≥ 50 用户上线 + +**命令骨架**(≈ 14 条): + +```bash +# 1. 接入 NSFWJS 到 Worker 的 T-6.5 +cd apps/worker +# src/pipeline/nsfw-check.ts 写完并加入 src/index.ts pipeline 注册 + +# 2. Edge Function E-14 report 完善 +cd ../.. +# supabase/functions/report/index.ts 写入 reports 表 + 通知 admin + +# 3. /admin/reports 路由 +cd apps/web +mkdir -p app/admin/reports +# 写 middleware.ts 检查 auth.users.app_metadata.role === 'admin' +# 写 app/admin/reports/page.tsx(工单队列 + 判定按钮) + +# 4. 添加 admin role 给 reviewer 账号 +psql "$DATABASE_URL" -c \ + "UPDATE auth.users SET raw_app_meta_data = jsonb_set(raw_app_meta_data, '{role}', '\"admin\"') WHERE email='reviewer@crowdroom.app';" + +# 5. 法律文档(MDX) +# apps/web/app/legal/privacy/page.mdx → 复制 plans/CrowdRoom/09_privacy.md +# apps/web/app/legal/terms/page.mdx +# apps/web/app/legal/community/page.mdx → 引用 plans/CrowdRoom/10_governance.md + +# 6. 生产环境部署 +vercel --prod --cwd apps/web +supabase functions deploy --project-ref +flyctl deploy -a crowdroom-worker + +# 7. Sentry 验证 +curl -X POST "https://app.crowdroom.app/api/test-sentry" +# 进入 Sentry 项目确认 issue 已上报 + +# 8. PostHog funnel 验证 +# 跑一遍「注册 → 扫描 → 上传 → 浏览 → 点赞」完整流程,进 PostHog 查 funnel + +# 9. TestFlight 推 closed beta +fastlane beta +# 在 App Store Connect 把 build 加入「CrowdRoom Closed Beta」群组(≤ 50 用户) + +# 10. 烟雾测试矩阵(覆盖 11 条契约 X1-X5 + Y1-Y6) +pnpm -w smoke + +# 11. tag + release +pnpm exec semantic-release +git tag v0.5-mvp +git push origin v0.5-mvp + +# 12. 写发布通告(README → 写一个「CrowdRoom v0.5-mvp 闭门内测开启」简短公告) +``` + +--- + +## 7. 关键风险与开源依赖的「逃生通道」 + +> 每条列出:**如果 X 项目突然废弃 / 被收购 / license 变更,我们的迁移路径是 Y**。 +> 与 [`ROADMAP.md`](ROADMAP.md) §4 风险登记册联动;本节聚焦「**开源 / 云依赖层面**」的逃生,不重复业务风险。 + +| # | 触发条件(依赖变脸) | 迁移路径 | 估计切换工时 | +|---|----------------------|----------|--------------| +| **E-1** | **Supabase 被收购 / 涨价 / 关停 Cloud** | 启 [`docker-compose.yml`](docker-compose.yml) 中的 [`supabase/supabase`](https://github.com/supabase/supabase) 自托管栈;DNS 切到自建 Caddy;Storage 底层 driver 从 R2 改 [`minio/minio`](https://github.com/minio/minio)。supabase-swift / @supabase/supabase-js SDK URL 仅改 base URL,**业务代码 0 修改** | 2 人周 | +| **E-2** | **Vercel 涨价 / 区域不可达** | Web App 切到 Cloudflare Pages(Next.js 全功能支持)或自建 [`caddyserver/caddy`](https://github.com/caddyserver/caddy) + Node SSR server;Edge Functions(OG endpoint)迁到 [`denoland/deno`](https://github.com/denoland/deno) Deno Deploy 或 Cloudflare Workers | 1.5 人周 | +| **E-3** | **gltf-transform 停更** | 直接 fork 到 `crowdroom/glTF-Transform`;核心需求(Draco / Meshopt / 节点重命名)只用到 ≤ 30% API,可自行维护;同时启动 [`google/draco`](https://github.com/google/draco) + [`zeux/meshoptimizer`](https://github.com/zeux/meshoptimizer) 直接调用做 Plan B | 1 人周 | +| **E-4** | **PostHog Cloud 涨价 / 关停** | 自托管 [`PostHog/posthog`](https://github.com/PostHog/posthog)(一行 helm install),或换 [`plausible/analytics`](https://github.com/plausible/analytics)(功能少但够用,funnel 需自建)| 1 人周(含数据迁移) | +| **E-5** | **Apple `usdzconvert` 在 GitHub Actions macOS runner 不可用** | 完全切到 [`PixarAnimationStudios/OpenUSD`](https://github.com/PixarAnimationStudios/OpenUSD) Linux 构建产物(pip install usd-core);Worker Dockerfile 已预装,无需新增运维 | 0.5 人周 | +| **E-6** | **Cloudflare R2 涨价 / 退出区域** | Storage 底层 driver 切 [`minio/minio`](https://github.com/minio/minio) 自托管 + Backblaze B2 异地灾备;CDN 切 [`bunnyway/bunny.net`](https://bunny.net) 或 Tencent EdgeOne(CN 用) | 1.5 人周 | +| **E-7** | **NSFWJS 模型 license 变更 / 不更新** | 换 [`bumble-tech/private-detector`](https://github.com/bumble-tech/private-detector) 或自训模型;短期内禁用自动审核、强化人工审核 [`/admin/reports`](04_web_app_plan.md) 工作台 | 1 人周 | +| **E-8** | **Sentry FSL 协议恶化 / 价格暴涨** | 切到 [`glitchtip/glitchtip-backend`](https://gitlab.com/glitchtip/glitchtip-backend)(完全 MIT、Sentry SDK 协议兼容,0 代码改动)| 0.5 人周 | +| **E-9** | **Three.js / R3F 核心维护者跑路** | Three.js 已被 mrdoob / Don McCurdy 等多人维护,停更概率 < 1%;如发生则锁版本 + 自维护补丁;最终极方案降级到 [`google/model-viewer`](https://github.com/google/model-viewer) 提供静态房间预览(功能退化但不挂) | 2 人周(功能降级) | +| **E-10** | **GitHub Actions 价格暴涨 / 不可用** | 自托管 [`actions/runner`](https://github.com/actions/runner) on 自有 VPS(Hetzner / 腾讯云);macOS runner 改用真实 Mac mini in office | 1.5 人周 | + +> **核心承诺再次声明**:本方案中**没有任何一个组件存在「无逃生通道」的硬依赖**——10 条逃生路径均已识别,迁移工时合计 ≤ 13 人周,相当于「2 个工程师 6.5 周可完成全栈迁离」。 + +--- + +## 8. 成本预算(MVP 阶段,月度) + +> 假设:单房间 source `.usdz` 30 MB / canonical `.glb` 5 MB / 每用户月均扫 5 个房间、浏览 50 个房间。 + +| 服务 / 组件 | 免费额度 | 50 DAU 估算成本 | 500 DAU 估算成本 | 5 000 DAU 估算成本 | 何时该切自托管 | +|---|---|---|---|---|---| +| **Supabase Cloud**(Pro) | 500 MB DB + 1 GB Storage + 5 GB Egress + 500k Edge invocations | $0 / 月(仍在免费档) | $25 / 月(Pro 起步) | $200-400 / 月(Storage + Egress + Compute) | 月费 > $500 时(≈ 5k DAU) | +| **Cloudflare R2** | 10 GB Storage / 1M class A + 10M class B ops | $0 | $5(≈ 300 GB) | $40(≈ 2 TB Storage) | 月费 > $200 | +| **Vercel** | 100 GB Bandwidth / 1k build minutes / Hobby plan | $0 | $20 / 月(Pro seat) | $80 / 月(Pro + Edge invocations) | 月费 > $200 | +| **Fly.io Worker** | 3 shared vCPU + 256 MB(小)免费 | $0-5(idle) | $30(1× 1 vCPU 2 GB) | $150(3-4× 实例) | 月费 > $300 | +| **Apple Developer** | – | $99 / 年(≈ $8.25 / 月) | 同左 | 同左 | 永远固定 | +| **Sentry Cloud** | 5k events / 月(Developer Plan) | $0 | $26 / 月(Team) | $80 / 月(含 transactions) | 月费 > $100,切 GlitchTip | +| **PostHog Cloud** | 1M events / 月 + Session Replay | $0 | $0(仍免费档) | $50 / 月 | 月费 > $200 | +| **GitHub Actions** | 2000 分钟 / 月(含 macOS x10 倍率) | $0 | $30 / 月(含 macOS runner) | $100 / 月 | 月费 > $300 | +| **域名 / DNS** | – | $1 / 月 | $1 | $1 | – | +| **合计(粗估)** | – | **≈ $10 / 月** | **≈ $140 / 月** | **≈ $700-1 000 / 月** | – | + +> **决策红线**:月度成本 > $1 000 即触发「自托管启动」评估;本方案的撤退路径在 §7 已完整说明,自托管 5k DAU 月度成本(含 VPS + 流量)估算 ≈ $300-500,明确低于纯云。 + +--- + +## 9. 工时估算与人员配比 + +| 角色 | 周次投入 | 主要任务对应章节 | +|---|---|---| +| **iOS 工程师 × 1** | W5 全职 + W1/W3/W7 各 20% | §6 W5(iOS 全部);其他周做 client SDK 联调 | +| **Web 工程师 × 1** | W1 / W4 / W7 全职 + 其他周 50% | §6 W1(脚手架)、W4(详情页+图层)、W7(Remix);W8 部署联调 | +| **全栈 / 后端 × 1** | W2 / W3 / W6 / W8 全职 + 其他周 50% | §6 W2(Schema)、W3(Edge Fn)、W6(Worker)、W8(治理+部署) | +| **设计师 × 0.3** | W1-W4 各 ≈ 10 小时 | UI 设计稿、4 层面板视觉规范、shadcn 主题、OG 卡片模板 | +| **PM × 0.2** | 贯穿 8 周 | sprint planning、DoR/DoD 核对、risk register 维护、内测招募 | +| **法务 / DPO 兼职** | W2 / W8 各 4 小时 | 隐私政策审核、CC0 资产合规审查 | + +**人周合计**:iOS 1 × 8 × 0.55 + Web 1 × 8 × 0.7 + 后端 1 × 8 × 0.7 + 设计 0.3 × 4 + PM 0.2 × 8 ≈ **17 个有效人周 / 8 周自然周**(≈ 32 人周计入并行 + 切换 + 评审折扣后的真实工时)。 + +> 与 [`ROADMAP.md`](ROADMAP.md) §2.1 M1 DoD 的「10 项必须全过」对齐;任何角色缺位 ≥ 1 周即触发 M1 延期评估。 + +--- + +## 10. M1 验收标准(出口清单) + +> 与 [`ROADMAP.md`](ROADMAP.md) §2.1 M1 DoD 严格对齐,并补充本执行方案特有的「开源工程化」验收点。 + +### 10.1 功能验收(12 条) + +- [ ] **F-01** iOS App 通过 TestFlight 审核,可邀请 ≥ 100 用户 +- [ ] **F-02** iOS 端能扫描房间、上传,**人脸端侧自动脱敏 p95 ≤ 15 s**(iPhone 12 Pro) +- [ ] **F-03** Web 端 `/` `/r/[room_id]` `/login` `/upload` `/u/[username]` 5 个核心路由可用 +- [ ] **F-04** 4 层(walls/floor/furniture/materials)可独立 toggle 可见性,viewState 可分享 +- [ ] **F-05** 1 张材质能在 Web 端 **p95 ≤ 200 ms** 热替换 +- [ ] **F-06** 1 件家具能 OBB 自动对齐替换,超 1.5× 时有警告 +- [ ] **F-07** Remix 端到端可用:fork → 编辑 → 自动保存 → 发布 → `/remix/[id]` 可见 +- [ ] **F-08** [`02_api_contract.md`](02_api_contract.md) 19 个端点中 MVP 必须的 **13 个**(E-01~E-13, E-15)线上可用 +- [ ] **F-09** 转码 Worker **p95 ≤ 60 s**(含 USDZ → glb + Draco + manifest 生成) +- [ ] **F-10** **100 个并发上传**,转码成功率 ≥ 95% +- [ ] **F-11** NSFWJS 接入 `upload-complete` 后端流水,命中后写 `moderation_signals`,在 `/admin/reports` 队列可见 +- [ ] **F-12** 治理:举报通道 E-14 可用,处罚阶梯 L1-L3 工具就绪 + +### 10.2 工程化验收(10 条) + +- [ ] **G-01** 全栈 `docker-compose up` 一键本地启动(Supabase + Worker + Redis + MinIO 撤退栈) +- [ ] **G-02** CI 三条 workflow 全绿(`ci-web` / `ci-ios` / `ci-worker`),任何 PR < 10 分钟反馈 +- [ ] **G-03** Lighthouse CI 桌面性能分 **≥ 85**(首页 + 详情页),移动 LCP ≤ 3 s +- [ ] **G-04** Trivy 扫描 Worker 镜像 0 个 HIGH/CRITICAL 漏洞 +- [ ] **G-05** Playwright E2E 覆盖 [`02_api_contract.md`](02_api_contract.md) §8 的 **11 条契约**(X1-X5 + Y1-Y6) +- [ ] **G-06** Sentry 接入 iOS / Web / Worker 三端,0 P0 issue 持续 ≥ 7 天 +- [ ] **G-07** PostHog funnel「注册 → 扫描 → 上传 → 浏览 → 点赞」5 步采集率 ≥ 30% +- [ ] **G-08** [`packages/shared-types/`](packages/shared-types/) 的 Zod schema 被 iOS / Web / Worker / Edge Function 4 端导入,无任何字段漂移 +- [ ] **G-09** semantic-release 自动 tag + 写 [`CHANGELOG.md`](CHANGELOG.md);v0.5-mvp tag 上线 +- [ ] **G-10** 所有依赖通过 Dependabot weekly 扫描;当前 0 个高危待修 + +### 10.3 合规验收(4 条) + +- [ ] **C-01** [`09_privacy.md`](09_privacy.md) §6.1 的 8 项 GDPR MVP 必做项全部上线(含 PrivacyManifest、Cookie 通知、隐私政策页) +- [ ] **C-02** 公共资产库 ≥ 30 件家具 + ≥ 20 种 PBR,**全部 CC0**;硬过滤拒收非 CC0 资产 +- [ ] **C-03** 退场承诺页 `/legal/exit-promise` 上线([`10_governance.md`](10_governance.md) §10 公开数据 CC0 镜像承诺) +- [ ] **C-04** E-18 三阶段账号注销 + E-19 数据导出在生产环境可端到端跑通 + +### 10.4 内测验收(2 条) + +- [ ] **B-01** 内部 dogfood ≥ 100 个真实房间上传无 P0 事故 +- [ ] **B-02** Closed Beta(≤ 50 用户)发布后 1 周内 NPS ≥ 20 + +**合计 28 条验收点**——全部勾绿即 M1 出口。 + +--- + +## 11. 本章小结 + +| 关键产出 | 一句话 | +|----------|--------| +| **77 个 GitHub repo** | 覆盖 iOS / Web / Worker / BaaS / 审核 / 资产 / DevOps / 撤退 8 层,全部 ≥ 1k star + 宽松 license + 近 6 月活跃 | +| **5 个深度评估** | gltf-transform / R3F+drei / Apple USD / supabase-swift / NSFWJS 全部「**MVP 推荐 + 风险已识别 + 逃生路径明确**」 | +| **1 个 Monorepo** | pnpm + Turborepo;iOS / Web / Worker / Edge Function / Schemas 同仓共版本;3 条理由 | +| **8 周 × ≈ 14 条命令** | W1 仓库初始化 → W8 closed beta;累计 **116 条可执行命令**,零伪命令 | +| **10 条逃生通道** | 任意单组件失效 ≤ 2 人周可迁移;全栈撤离 ≤ 13 人周 | +| **28 条 M1 验收点** | 功能 12 + 工程化 10 + 合规 4 + 内测 2,与 [`ROADMAP.md`](ROADMAP.md) §2.1 严格对齐 | + +读完本章你应能: +- ✅ 给一个新工程师一份「**从 W1 第一条 `mkdir crowdroom` 命令到 W8 closed beta 发布**」的可执行清单 +- ✅ 在任意云供应商变脸时 ≤ 2 周内完成栈迁移 +- ✅ 用 < $200 / 月的成本支撑 500 DAU;切自托管后 < $500 / 月支撑 5k DAU +- ✅ 在 PR review 时按 §10 28 条 checkbox 逐条核对 M1 出口 + +--- + +**文档版本**:v0.1 · 2026-05-19 +**维护者**:CrowdRoom 工程组 +**下一步阅读**:完成 W0 §5 准备清单后 → 进入 §6.1 W1 第一条命令。 \ No newline at end of file diff --git a/plans/CrowdRoom/README.md b/plans/CrowdRoom/README.md new file mode 100644 index 0000000..e506a11 --- /dev/null +++ b/plans/CrowdRoom/README.md @@ -0,0 +1,152 @@ +# CrowdRoom · 项目入口(README) + +> **一句话定位**:**RoomPlan 版 Sketchfab + Pinterest** —— 一个让普通 iPhone 用户「扫一扫家、传上来、被别人 Remix」的众包房间扫描共享平台。 + +> **电梯陈述(80 字)**:CrowdRoom 用 iOS RoomPlan 把每个房间变成一份「墙/地/家具/材质」四层数据,公开数据走 CDN,私有数据走 Supabase RLS,浏览器里直接在别人的房间上换材质、换家具、发表 Remix——零下载、零安装、零专业门槛。 + +> **当前版本**:v0.2(2026-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 个里程碑(M1–M4)DoR/DoD + 12 条风险登记册 + 8 条 P2 候选 | PM / 创始人 / Tech Lead | +| – | 变更日志 | [`CHANGELOG.md`](CHANGELOG.md) | Keep a Changelog 风格,记录 v0.1(初版 7 文档)→ v0.2(10 个 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
RoomPlan 采集 + 端侧脱敏 + 三段式上传] + WEB[Web 前端
R3F + 4 层图层 + 材质家具替换 + Remix] + end + + subgraph BAAS[Supabase BaaS] + AUTH[Auth
Apple / Google / Email] + DB[Postgres 9 表
+ RLS + tsvector 搜索] + STG[Storage
公开 + 私有 双桶] + EDGE[Edge Functions
19 端点] + RT[Realtime
转码进度推送] + end + + subgraph WORKER[渲染与转码层] + TRANS[Transcode Worker
USDZ → glb + Draco/Meshopt] + ASSET[Asset Library
CC0 公共素材] + end + + CDN[CDN
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 不复制 1–10 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 周怎么落地。 diff --git a/plans/CrowdRoom/ROADMAP.md b/plans/CrowdRoom/ROADMAP.md new file mode 100644 index 0000000..70a180d --- /dev/null +++ b/plans/CrowdRoom/ROADMAP.md @@ -0,0 +1,258 @@ +# CrowdRoom · 路线图(ROADMAP) + +> **文档目的**:把 7 份设计文档([`00`](00_overview.md)–[`10`](10_governance.md))落地为 4 个可被 sprint planning 直接使用的里程碑,配套合并后的风险登记册、跨任务依赖图、v0.3 已知缺口、P2+ 候选清单。 +> +> **基准时间**:2026-05-19(v0.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** | W1–W8 | v0.5-mvp | 跑通「扫描 → 上传 → 浏览 → 4 层切换 → 换材质」最小闭环 | iOS TestFlight + Web Vercel + Supabase 9 表 + Worker + 30+ CC0 素材 | +| **M2 v0.5** | W9–W16 | v0.5 | 把 Remix 与社区互动跑顺 | 家具替换 OBB 对齐 + Remix 发布 + 评论点赞 + 公共资产库 v1 | +| **M3 v1.0** | W17–W24 | 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 MVP(W1–W8) + +**DoR(5 项)**: +- [ ] 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) + +**DoD(10 项)**: +- [ ] 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 层固定 ID(walls/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.5(W9–W16) + +**DoR(4 项)**: +- [ ] 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 误差 + +**DoD(8 项)**: +- [ ] 家具替换可用:替换面板 + 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.0(W17–W24) + +**DoR(4 项)**: +- [ ] 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) + +**DoD(8 项)**: +- [ ] 全文搜索可用:`/search?q=` + 标签筛选 + 排序(热度 / 新发布 / Remix 数) +- [ ] Named Views 可用:4 层组合可命名保存、可被分享、可被嵌入 +- [ ] iframe 嵌入 `