- 处理: 74 个 .md 文件 - 跳过: 0 个(无已存在的 front matter) - 异常: 3 个(H1 缺失,用文件名兜底) - plans/PRISM/.research/readmes/3d-llm.md - plans/PRISM/.research/readmes/openmask3d.md - plans/PRISM/.research/readmes/openscene.md
44 KiB
title, date, draft, tags, categories
| title | date | draft | tags | categories | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| CrowdRoom · 数据模型与 Storage 规范(v0.2) | 2026-05-20 | false |
|
|
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§4 P-3 / P-4 与10_governance.md§4 P-W-3。
本章承接
00_overview.md§4 架构图与 §5 技术栈,落地 Supabase Postgres 的 DDL、RLS、Storage 目录、layer_manifest.jsonSchema,以及与plans/PRISM/03_data_schema.mdL1–L4 的映射。DDL / JSON Schema / 字段名一律英文;解释文字用简体中文。
1. 关键决策(先拍板,再展开)
| # | 决策 | 拍板结论 | 一句话理由 |
|---|---|---|---|
| D1 | 7 表 vs 8 表(是否拆 room_versions) |
拆为 8 表,新增 room_versions |
转码是异步且会失败的、Remix 必须锁定父版本、重传不应破坏旧链接——三者都强烈需要一个独立的版本实体;与 PRISM snapshots/ 思路一致(plans/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)
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是 Supabaseauth.users的影子表(public.users),其余 8 张是 CrowdRoom 业务表。
🔄 v0.2 — 回写自 G-10:本节 ER 图中
rooms实体的location_city字段在 v0.2 起以业务别名location_label TEXT NULL(城市级 5 km 精度标签)对外表述,与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§4 P-W-3 「父房间硬删时把最后一个 ready 公开版本的几何快照转移到 Remix」的落地。
3. PostgreSQL DDL(在 Supabase SQL Editor 中可直接运行)
3.0 前置 enum 与扩展
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 的公开影子表)
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
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
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)。
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(隐私脱敏)
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
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§7 错误码REMIX_PARENT_DELETED的解释(删除前必须先迁移到 tombstone 或转为软删除)。
3.7 comments
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
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(公共素材库)
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 Functionroom-delete-with-snapshot(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§7)。注意:只复制几何与 manifest,不复制redactions[]表行——这是10_governance.md§4 P-W-3 与09_privacy.md§4 P-2 的隐私边界对齐。
3.11 软删 vs 硬删分层策略(v0.2 新增)
🔄 v0.2 — 回写自 G-9:本节根据
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_versionscascade;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 表为例):
-- 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
-- 账号注销:业务 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§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 Functionroom-delete-with-snapshot/02_api_contract.md§2 E-16)按下列顺序执行:
- 遍历该 room 的所有
version_no中所有is_public=true AND deleted_at IS NULL的 Remix 子代- 对每个被引用的
room_versions行,把canonical_glb_path与manifest_path指向的对象复制(不是 move,避免中途失败丢父)到remix-fallbacks/{parent_room_id}/v{n}/canonical.glb与.../layer_manifest.json- 更新
remixes.parent_snapshot_path = 'remix-fallbacks/{parent_room_id}/v{n}/canonical.glb'(事务内)- 全部 Remix 写完后再执行
delete from public.rooms where id = $1(cascade 子表与原 Storage 路径清理)- 任一步失败 → 整体事务回滚 + 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 定义
{
"$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 行)
{
"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 §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 表配合 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 在此 schema 上定义端点、Edge Function 与转码流水。
章节版本:v0.1 · 草案
关键收获:CrowdRoom 落到 Supabase 上 = 9 张表 + 2 个 Storage bucket + 1 份 layer_manifest.json Schema;与 PRISM 的关系是"展示层 4 层 ≈ L2/L4 降维投影"。