# 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 降维投影"。