Files
gaojie 1ea74b46da
Sync to site1 / sync (push) Has been cancelled
chore: update CrowdRoom categories from worldmodel to CrowdRoom
2026-05-21 02:20:22 +08:00

989 lines
44 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "CrowdRoom · 数据模型与 Storage 规范(v0.2"
date: 2026-05-20
draft: false
tags: ["CrowdRoom", "众包", "3D 重建", "隐私", "Web", "数据结构"]
categories: ["CrowdRoom"]
---
# CrowdRoom · 数据模型与 Storage 规范(v0.2
> **版本**v0.22026-05-19
> **v0.2 修订**:回写 G-1`remixes.parent_snapshot_path`)、G-9(所有用户数据表 `deleted_at` 软删字段 + 软/硬删分层 RLS)、G-10`rooms.location_label` 显式标注);本次仅做「追加 / 字段插入 / 文案润色」,未改任何既有字段语义。源决策见 [`09_privacy.md`](09_privacy.md) §4 P-3 / P-4 与 [`10_governance.md`](10_governance.md) §4 P-W-3。
> 本章承接 [`00_overview.md`](00_overview.md) §4 架构图与 §5 技术栈,落地 Supabase Postgres 的 DDL、RLS、Storage 目录、`layer_manifest.json` Schema,以及与 [`plans/PRISM/03_data_schema.md`](../PRISM/03_data_schema.md) L1L4 的映射。
>
> **DDL / JSON Schema / 字段名一律英文**;解释文字用简体中文。
---
## 1. 关键决策(先拍板,再展开)
| # | 决策 | 拍板结论 | 一句话理由 |
|---|------|----------|------------|
| D1 | 7 表 vs 8 表(是否拆 `room_versions` | **拆为 8 表**,新增 [`room_versions`](#22-room_versions) | 转码是异步且会失败的、Remix 必须锁定父版本、重传不应破坏旧链接——三者都强烈需要一个独立的版本实体;与 PRISM `snapshots/` 思路一致([`plans/PRISM/03_data_schema.md`](../PRISM/03_data_schema.md) §3.4 |
| D2 | 隐私脱敏元数据放 `room_versions` 还是独立 `redactions` 表 | **独立 `redactions` 表**(一对多) | 同一版本可有多个脱敏框(人脸 N 个、镜面 M 个、用户手标 K 个),用嵌入 JSONB 会把审计查询变成全表扫;独立表可索引 `kind``applied_at` |
| D3 | 4 层 ID 是否固定 | **固定为 `walls / floor / furniture / materials`**,写死在 enum | 减少前端切换器的字符串拼接错误;任何"未知层"在 Worker 阶段就拒收,避免脏数据进入 Web |
| D4 | 材质层的存储形态 | **逻辑层**`materials``layer_manifest.json` 里以 `slots[]` 出现,不在 Postgres `layers` 表中独立成行 | 材质本质是其它三层 mesh 的 PBR 槽位映射,独立成 SQL 行会导致大量 JOIN;用 manifest 内嵌可以一次拉取完整图层视图 |
| D5 | `rooms` 的全文搜索 | **`tsvector` + GIN 索引**,由触发器从 `title / description / tags` 自动合成 | Supabase 原生支持,零额外依赖;如未来上 Algolia/Meili 再加 outbox 即可 |
---
## 2. 数据模型总览(erDiagram
```mermaid
erDiagram
users ||--o{ rooms : owns
users ||--o{ remixes : creates
users ||--o{ comments : writes
users ||--o{ likes : gives
rooms ||--o{ room_versions : has
rooms ||--o{ comments : receives
rooms ||--o{ likes : receives
rooms ||--o{ remixes : forked_into
room_versions ||--o{ layers : contains
room_versions ||--o{ redactions : applies
remixes }o--|| room_versions : forks_from
assets ||--o{ remixes : referenced_by
users {
uuid id PK
text handle
text display_name
text avatar_url
timestamptz created_at
}
rooms {
uuid id PK
uuid owner_id FK
text title
text description
text[] tags
text visibility
uuid current_version_id FK
tsvector search_tsv
timestamptz created_at
}
room_versions {
uuid id PK
uuid room_id FK
int version_no
text status
text source_usdz_path
text source_json_path
text canonical_glb_path
text manifest_path
text thumbnail_path
jsonb roomplan_summary
timestamptz created_at
}
layers {
uuid id PK
uuid version_id FK
text layer_kind
jsonb manifest_node
}
redactions {
uuid id PK
uuid version_id FK
text kind
jsonb region
text source
timestamptz applied_at
}
remixes {
uuid id PK
uuid parent_version_id FK
uuid author_id FK
jsonb overlay
text title
timestamptz created_at
}
comments {
uuid id PK
uuid room_id FK
uuid author_id FK
text body
timestamptz created_at
}
likes {
uuid id PK
uuid room_id FK
uuid user_id FK
timestamptz created_at
}
assets {
uuid id PK
text kind
text glb_path
jsonb pbr
text license
text semantic_class
}
```
> 共 **9 个实体盒**,其中 `users` 是 Supabase `auth.users` 的影子表(`public.users`),其余 8 张是 CrowdRoom 业务表。
> 🔄 **v0.2 — 回写自 G-10**:本节 ER 图中 `rooms` 实体的 `location_city` 字段在 v0.2 起以**业务别名** `location_label TEXT NULL`(城市级 5 km 精度标签)对外表述,与 [`09_privacy.md`](09_privacy.md) §3.3 / §4 P-3 决策对齐。DDL 字段名保持 `location_city` 不动以避免破坏既有迁移脚本;新增的别名仅用于跨文档术语统一(见 §3.2 字段块脚注)。
>
> 🔄 **v0.2 — 回写自 G-9**:本节 ER 图所有用户数据实体(`users / rooms / room_versions / remixes / comments / likes`)在 v0.2 起新增 `deleted_at TIMESTAMPTZ NULL` 软删字段(图中未画出以避免拥挤,详见 §3 各表 DDL 与 §3.11 软/硬删分层 RLS 策略)。
>
> 🔄 **v0.2 — 回写自 G-1**:本节 ER 图 `remixes` 实体在 v0.2 起新增 `parent_snapshot_path TEXT NULL` 字段,用于 [`10_governance.md`](10_governance.md) §4 P-W-3 「父房间硬删时把最后一个 ready 公开版本的几何快照转移到 Remix」的落地。
---
## 3. PostgreSQL DDL(在 Supabase SQL Editor 中可直接运行)
### 3.0 前置 enum 与扩展
```sql
create extension if not exists "pgcrypto"; -- gen_random_uuid()
create extension if not exists "pg_trgm"; -- trigram 模糊搜索
create type room_visibility as enum ('public', 'unlisted', 'private');
create type version_status as enum ('uploading', 'queued', 'transcoding',
'ready', 'failed', 'archived');
create type layer_kind as enum ('walls', 'floor', 'furniture', 'materials');
create type redaction_kind as enum ('face', 'mirror', 'logo', 'user_marked', 'plate');
create type asset_kind as enum ('furniture', 'material');
```
### 3.1 `users`auth.users 的公开影子表)
```sql
create table public.users (
id uuid primary key references auth.users(id) on delete cascade,
handle text unique not null check (handle ~ '^[a-zA-Z0-9_]{3,24}$'),
display_name text not null,
avatar_url text,
bio text,
created_at timestamptz not null default now(),
-- v0.2 / G-9:账号注销 T+0 软删 / T+7 不可撤 / T+30 硬删(详见 §3.11 与 09_privacy §4 P-4
deleted_at timestamptz null
);
create index users_handle_trgm on public.users using gin (handle gin_trgm_ops);
alter table public.users enable row level security;
create policy users_select_all on public.users for select using (true);
create policy users_update_self on public.users for update
using (auth.uid() = id) with check (auth.uid() = id);
create policy users_insert_self on public.users for insert
with check (auth.uid() = id);
-- DELETE 不开放:由 auth.users 级联
```
### 3.2 `rooms`
```sql
create table public.rooms (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references public.users(id) on delete cascade,
title text not null check (char_length(title) between 1 and 120),
description text check (char_length(description) <= 4000),
tags text[] not null default '{}',
visibility room_visibility not null default 'public',
current_version_id uuid, -- 延迟外键,避免与 room_versions 形成创建死锁
cover_color text, -- 16 进制主色,用作占位
location_city text, -- 用户自填,不做 GPS
-- v0.2 / G-10location_label 是 location_city 的业务别名,城市级 5 km 精度(对应 P-3)
-- 不新增列;location_city 字段语义=「城市级标签」,跨文档(09_privacy / 04_web_app_plan
-- 一律以 location_label 称呼。如未来需要彻底重命名,需走 v0.3 迁移脚本。
like_count int not null default 0,
remix_count int not null default 0,
comment_count int not null default 0,
search_tsv tsvector,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
-- v0.2 / G-9:软删字段(业务 API 走软删;service_role cron T+30 后物理 cascade 删)
deleted_at timestamptz null
);
create index rooms_owner on public.rooms (owner_id);
create index rooms_visibility on public.rooms (visibility) where visibility = 'public';
create index rooms_tags_gin on public.rooms using gin (tags);
create index rooms_search_gin on public.rooms using gin (search_tsv);
create index rooms_created_desc on public.rooms (created_at desc);
-- tsvector 自动同步
create function rooms_tsv_trigger() returns trigger as $$
begin
new.search_tsv :=
setweight(to_tsvector('simple', coalesce(new.title, '')), 'A') ||
setweight(to_tsvector('simple', array_to_string(new.tags, ' ')), 'B') ||
setweight(to_tsvector('simple', coalesce(new.description, '')), 'C');
new.updated_at := now();
return new;
end $$ language plpgsql;
create trigger rooms_tsv_update
before insert or update of title, description, tags
on public.rooms for each row execute function rooms_tsv_trigger();
alter table public.rooms enable row level security;
-- 公开/不公开列表
create policy rooms_select_public on public.rooms for select
using (visibility in ('public', 'unlisted') or owner_id = auth.uid());
create policy rooms_insert_self on public.rooms for insert
with check (owner_id = auth.uid());
create policy rooms_update_owner on public.rooms for update
using (owner_id = auth.uid()) with check (owner_id = auth.uid());
create policy rooms_delete_owner on public.rooms for delete
using (owner_id = auth.uid());
```
### 3.3 `room_versions`
```sql
create table public.room_versions (
id uuid primary key default gen_random_uuid(),
room_id uuid not null references public.rooms(id) on delete cascade,
version_no int not null,
status version_status not null default 'uploading',
-- Storage object 路径(不含 bucket 名)
source_usdz_path text,
source_json_path text,
canonical_glb_path text,
manifest_path text,
thumbnail_path text,
preview_mp4_path text,
-- 转码摘要:从 RoomPlan JSON 抽取的统计(房间面积、家具数等)
roomplan_summary jsonb,
bytes_source bigint,
bytes_canonical bigint,
transcode_error text,
transcode_attempts int not null default 0,
created_at timestamptz not null default now(),
ready_at timestamptz,
-- v0.2 / G-9:版本级软删(避免删除转码失败/旧版本时立即丢失审计)
deleted_at timestamptz null,
unique (room_id, version_no)
);
create index room_versions_room on public.room_versions (room_id);
create index room_versions_status on public.room_versions (status);
-- 补外键:rooms.current_version_id → room_versions.id
alter table public.rooms
add constraint rooms_current_version_fk
foreign key (current_version_id) references public.room_versions(id)
on delete set null deferrable initially deferred;
alter table public.room_versions enable row level security;
create policy versions_select_via_room on public.room_versions for select
using (
exists (select 1 from public.rooms r
where r.id = room_id
and (r.visibility in ('public','unlisted') or r.owner_id = auth.uid()))
);
create policy versions_insert_owner on public.room_versions for insert
with check (
exists (select 1 from public.rooms r
where r.id = room_id and r.owner_id = auth.uid())
);
-- UPDATE 仅供 Edge Function 通过 service_role 调用(绕过 RLS);
-- 显式 policy 也开给 owner,便于"重命名/重新触发"等运营动作。
create policy versions_update_owner on public.room_versions for update
using (
exists (select 1 from public.rooms r
where r.id = room_id and r.owner_id = auth.uid())
);
create policy versions_delete_owner on public.room_versions for delete
using (
exists (select 1 from public.rooms r
where r.id = room_id and r.owner_id = auth.uid())
);
```
### 3.4 `layers`
> 仅落 `walls / floor / furniture` 三层(材质走 manifest 内嵌,见 D4)。一行 = 一层;`manifest_node` 是该层在 `layer_manifest.json` 中的子树拷贝(冗余存储,便于 Postgres 端聚合查询,不必每次拉 Storage)。
```sql
create table public.layers (
id uuid primary key default gen_random_uuid(),
version_id uuid not null references public.room_versions(id) on delete cascade,
layer_kind layer_kind not null,
-- 节点数 / bbox 体积等便于排序/筛选的快查字段
item_count int not null default 0,
bbox_volume numeric(10,3),
manifest_node jsonb not null,
unique (version_id, layer_kind)
);
create index layers_version on public.layers (version_id);
create index layers_kind on public.layers (layer_kind);
alter table public.layers enable row level security;
create policy layers_select_via_version on public.layers for select
using (
exists (select 1 from public.room_versions v
join public.rooms r on r.id = v.room_id
where v.id = version_id
and (r.visibility in ('public','unlisted') or r.owner_id = auth.uid()))
);
create policy layers_write_via_owner on public.layers for all
using (
exists (select 1 from public.room_versions v
join public.rooms r on r.id = v.room_id
where v.id = version_id and r.owner_id = auth.uid())
)
with check (
exists (select 1 from public.room_versions v
join public.rooms r on r.id = v.room_id
where v.id = version_id and r.owner_id = auth.uid())
);
```
### 3.5 `redactions`(隐私脱敏)
```sql
create table public.redactions (
id uuid primary key default gen_random_uuid(),
version_id uuid not null references public.room_versions(id) on delete cascade,
kind redaction_kind not null,
-- region 统一用归一化坐标:
-- 2D(贴图上):{"space":"texture","tex_id":"...", "bbox":[x,y,w,h]}0-1
-- 3D(世界系):{"space":"world", "obb":{"center":[x,y,z],
-- "extent":[ex,ey,ez],"quat":[w,x,y,z]}}
region jsonb not null,
source text not null check (source in ('auto_vision','user','moderator')),
confidence numeric(4,3), -- 0-1,仅 source='auto_vision' 时有意义
applied_at timestamptz not null default now(),
note text
);
create index redactions_version on public.redactions (version_id);
create index redactions_kind on public.redactions (kind);
alter table public.redactions enable row level security;
-- 只有 owner 可读完整列表(避免攻击者通过脱敏记录反推敏感位置)
create policy redactions_owner_only on public.redactions
for all using (
exists (select 1 from public.room_versions v
join public.rooms r on r.id = v.room_id
where v.id = version_id and r.owner_id = auth.uid())
);
```
### 3.6 `remixes`
```sql
create table public.remixes (
id uuid primary key default gen_random_uuid(),
parent_version_id uuid not null references public.room_versions(id) on delete restrict,
author_id uuid not null references public.users(id) on delete cascade,
title text not null check (char_length(title) between 1 and 120),
overlay jsonb not null, -- remix_overlay.json,详见 02_api_contract.md §4
thumbnail_path text,
is_public boolean not null default true,
like_count int not null default 0,
created_at timestamptz not null default now(),
-- v0.2 / G-9Remix 软删(作者注销时统一走软删流;30 天可恢复)
deleted_at timestamptz null,
-- v0.2 / G-1:父房间硬删时由 Edge Function room-delete-with-snapshot 写入此字段
-- 值形如 'public/remix-fallbacks/{parent_room_id}/v{n}/canonical.glb'
-- 表示该 Remix 已脱离原父版本,几何由平台镜像承载(详见 10_governance §4 P-W-3
parent_snapshot_path text null
);
create index remixes_parent on public.remixes (parent_version_id);
create index remixes_author on public.remixes (author_id);
create index remixes_public_recent
on public.remixes (created_at desc) where is_public;
alter table public.remixes enable row level security;
create policy remixes_select_public on public.remixes for select
using (is_public or author_id = auth.uid());
create policy remixes_insert_self on public.remixes for insert
with check (
author_id = auth.uid()
and exists (
select 1 from public.room_versions v
join public.rooms r on r.id = v.room_id
where v.id = parent_version_id
and v.status = 'ready'
and r.visibility in ('public','unlisted'))
);
create policy remixes_update_owner on public.remixes for update
using (author_id = auth.uid()) with check (author_id = auth.uid());
create policy remixes_delete_owner on public.remixes for delete
using (author_id = auth.uid());
```
> 父版本删除策略选 `on delete restrict` 而不是 `cascade`——见 [`02_api_contract.md`](02_api_contract.md) §7 错误码 `REMIX_PARENT_DELETED` 的解释(删除前必须先迁移到 tombstone 或转为软删除)。
### 3.7 `comments`
```sql
create table public.comments (
id uuid primary key default gen_random_uuid(),
room_id uuid not null references public.rooms(id) on delete cascade,
author_id uuid not null references public.users(id) on delete cascade,
body text not null check (char_length(body) between 1 and 1000),
reply_to uuid references public.comments(id) on delete set null,
created_at timestamptz not null default now(),
-- v0.2 / G-9:评论软删(作者删评论 / 注销 → 标 deleted_at30 天后硬删)
deleted_at timestamptz null
);
create index comments_room on public.comments (room_id, created_at desc);
create index comments_author on public.comments (author_id);
alter table public.comments enable row level security;
create policy comments_select_public on public.comments for select
using (
exists (select 1 from public.rooms r
where r.id = room_id
and (r.visibility in ('public','unlisted') or r.owner_id = auth.uid()))
);
create policy comments_insert_self on public.comments for insert
with check (author_id = auth.uid());
create policy comments_delete_owner_or_room on public.comments for delete
using (
author_id = auth.uid()
or exists (select 1 from public.rooms r
where r.id = room_id and r.owner_id = auth.uid())
);
```
### 3.8 `likes`
```sql
create table public.likes (
id uuid primary key default gen_random_uuid(),
room_id uuid not null references public.rooms(id) on delete cascade,
user_id uuid not null references public.users(id) on delete cascade,
created_at timestamptz not null default now(),
-- v0.2 / G-9:点赞软删(多用于注销级联软删;用户手动「取消点赞」走 DELETE 物理删)
deleted_at timestamptz null,
unique (room_id, user_id)
);
create index likes_room on public.likes (room_id);
create index likes_user on public.likes (user_id);
alter table public.likes enable row level security;
create policy likes_select_public on public.likes for select using (true);
create policy likes_insert_self on public.likes for insert
with check (user_id = auth.uid());
create policy likes_delete_self on public.likes for delete
using (user_id = auth.uid());
-- 计数同步触发器(避免每次 select count(*)
create function bump_room_like_count() returns trigger as $$
begin
if tg_op = 'INSERT' then
update public.rooms set like_count = like_count + 1 where id = new.room_id;
elsif tg_op = 'DELETE' then
update public.rooms set like_count = greatest(0, like_count - 1) where id = old.room_id;
end if;
return null;
end $$ language plpgsql;
create trigger likes_count_after
after insert or delete on public.likes
for each row execute function bump_room_like_count();
```
### 3.9 `assets`(公共素材库)
```sql
create table public.assets (
id uuid primary key default gen_random_uuid(),
kind asset_kind not null,
name text not null,
semantic_class text, -- 对齐 RoomPlan 16 类家具,如 'sofa','table'
glb_path text, -- furniture 用
pbr jsonb, -- material 用:{base_color, normal, roughness, metallic, ao}
thumbnail_path text not null,
license text not null default 'CC0',
source_url text,
tags text[] not null default '{}',
created_at timestamptz not null default now()
);
create index assets_kind on public.assets (kind);
create index assets_class on public.assets (semantic_class);
create index assets_tags on public.assets using gin (tags);
alter table public.assets enable row level security;
create policy assets_select_all on public.assets for select using (true);
-- INSERT/UPDATE/DELETE 仅 service_role(运营后台),不写 policy 即可关闭
```
### 3.10 外键级联策略一览
| 父表 → 子表 | on delete | 理由 |
|------------|-----------|------|
| `auth.users → public.users` | cascade | 注销账号即清影子表 |
| `users → rooms / remixes / comments / likes` | cascade | 用户注销即清其内容(GDPR) |
| `rooms → room_versions / comments / likes` | cascade | 删房即清版本与互动 |
| `room_versions → layers / redactions` | cascade | 版本即子树根 |
| `room_versions → remixes` | **restrict** | 父被引用则禁止物理删除,必须先 tombstone |
| `users → assets` | n/a | 公共素材库与用户解耦 |
> 🔄 **v0.2 — 回写自 G-1**:上表中 `room_versions → remixes` 的 `restrict` 语义在 v0.2 起由 Edge Function `room-delete-with-snapshot`[`02_api_contract.md`](02_api_contract.md) §2 E-16)显式兑现:**父 room 硬删时,service_role 必须遍历所有指向该 room 任一 version 的 `remixes` 行,把对应 `room_versions.canonical_glb_path / manifest_path` 复制到 `public/remix-fallbacks/{parent_room_id}/v{n}/` 下,并把新路径写入 `remixes.parent_snapshot_path`**,然后才允许 `delete from public.rooms where id = $1`。该步骤失败时回滚整个事务并返回业务码 `PARENT_SNAPSHOT_TRANSFER_FAILED`[`02_api_contract.md`](02_api_contract.md) §7)。注意:**只复制几何与 manifest,不复制 `redactions[]` 表行**——这是 [`10_governance.md`](10_governance.md) §4 P-W-3 与 [`09_privacy.md`](09_privacy.md) §4 P-2 的隐私边界对齐。
### 3.11 软删 vs 硬删分层策略(v0.2 新增)
> 🔄 **v0.2 — 回写自 G-9**:本节根据 [`09_privacy.md`](09_privacy.md) §4 P-4「注销账号 T+0 / T+7 / T+30 三阶段」追加。所有 `deleted_at` 字段(§3.1 / §3.2 / §3.3 / §3.6 / §3.7 / §3.8)共享下述策略;表 `layers` / `redactions` / `assets` **不**加 `deleted_at`(前两者随 `room_versions` cascade`assets` 是平台资产无用户归属)。
#### 3.11.1 两层删除模型
| 层 | 调用方 | 操作 | 数据状态 |
|----|--------|------|---------|
| **业务 APIPostgREST + Edge Function** | iOS / Web 终端 | `UPDATE ... SET deleted_at = now()` | 行物理保留;现有 RLS 通过 `AND deleted_at IS NULL` 让普通查询「看不见」该行 |
| **service_role 物理删 cron** | 平台调度(`pg_cron`,每日 02:00 | `DELETE FROM ... WHERE deleted_at < now() - INTERVAL '30 days'` | 行真正消失;通过既有 ON DELETE CASCADE 链式清除子表 |
#### 3.11.2 现有 RLS policy 的 v0.2 补丁
所有 `*_select_*``*_select_via_*` policy 的 `USING` 子句需追加 `AND deleted_at IS NULL`(影子表 `users``rooms``room_versions``remixes``comments``likes`)。示例(以 `rooms` 表为例):
```sql
-- v0.2 补丁:在已有 policy 上叠加软删过滤
drop policy rooms_select_public on public.rooms;
create policy rooms_select_public on public.rooms for select
using (
deleted_at is null
and (visibility in ('public', 'unlisted') or owner_id = auth.uid())
);
-- room_versions / remixes / comments / likes / users 同理追加 `and deleted_at is null`
-- 注:owner 自查时也走 deleted_at is null;如需查看自己的「回收站」走单独 RPC,
-- 由 Edge Function account-export02_api_contract.md §2 E-18)暴露。
```
#### 3.11.3 软删触发与物理删 cron
```sql
-- 账号注销:业务 API 不直接走 SQL,而是调 Edge Function account-delete (E-17)
-- 该函数在 service_role 下执行:
-- step 1 (T+0): UPDATE users SET deleted_at=now() WHERE id=$1;
-- UPDATE rooms SET visibility='private', deleted_at=now() WHERE owner_id=$1;
-- UPDATE remixes/comments/likes SET deleted_at=now() WHERE author_id/user_id=$1;
-- -- auth.users JWT 即刻失效
-- step 2 (T+7): 业务 API 拒绝撤销请求(403 ACCOUNT_DELETION_IN_PROGRESS
-- step 3 (T+30): 走下述 pg_cron 物理删
-- pg_cron 每日 02:00 物理删 (示意)
-- 删 users 前必须先处理其 rooms 的 remix 快照转移 (G-1 / E-16 流程)
select cron.schedule('crowdroom_hard_delete', '0 2 * * *', $$
-- 1. 先对 rooms 走快照转移 (服务端调 room-delete-with-snapshot 等价逻辑)
-- 2. 再 cascade 删 users
delete from public.users where deleted_at < now() - interval '30 days';
delete from public.rooms where deleted_at < now() - interval '30 days';
delete from public.remixes where deleted_at < now() - interval '30 days';
delete from public.comments where deleted_at < now() - interval '30 days';
delete from public.likes where deleted_at < now() - interval '30 days';
delete from public.room_versions where deleted_at < now() - interval '30 days';
$$);
```
#### 3.11.4 与 RLS 现状的兼容性
- **service_role 绕过 RLS**cron 物理删与快照转移用 service_role JWT,不受软删过滤影响
- **owner 自查回收站**:MVP 不开放 UI 入口;如需,走 Edge Function `account-export`E-18)一次性导出全部 `deleted_at IS NOT NULL`
- **审计需求**:审计日志 / Sentry 事件需关联软删用户的 `id` 时,从 `auth.users` 影子表查(保留期受 [`09_privacy.md`](09_privacy.md) §8 保留期表约束)
---
## 4. Storage 目录结构
Supabase Storage 用两个 bucket
| Bucket | 公私 | 内容 |
|--------|------|------|
| `rooms` | **public**(公开作品的 glb/缩略图/manifest 走 CDN | 每个房间一棵子树 |
| `private` | **private**(原始 `.usdz` / `.roomplan.json` / 失败转码日志) | 仅 owner + service_role 可访问 |
### 4.1 公开 bucket 目录
```
rooms/{room_id}/v{version_no}/
├── canonical.glb # Web 端拉取的唯一几何文件(Draco 压缩)
├── layer_manifest.json # 4 层索引,详见 §5
├── thumbnail.webp # 640×360 主缩略图
├── thumbnail@2x.webp # 1280×720 高清版
├── preview.mp4 # 可选:5s 360° 自动旋转预览
└── overlays/
└── {remix_id}.json # 该房间衍生的 remix overlay 副本(CDN 缓存)
remix-fallbacks/{parent_room_id}/v{parent_version_no}/ # v0.2 / G-1 新增
├── canonical.glb # 父硬删时从原 rooms/.../ 复制过来的几何镜像
└── layer_manifest.json # 同上;redactions 表行不复制(隐私边界,见 §3.10 v0.2 段)
```
> 🔄 **v0.2 — 回写自 G-1**:上图新增 `remix-fallbacks/` 顶级目录。父房间硬删流程(Edge Function `room-delete-with-snapshot` / [`02_api_contract.md`](02_api_contract.md) §2 E-16)按下列顺序执行:
>
> 1. 遍历该 room 的所有 `version_no` 中所有 `is_public=true AND deleted_at IS NULL` 的 Remix 子代
> 2. 对每个被引用的 `room_versions` 行,把 `canonical_glb_path` 与 `manifest_path` 指向的对象**复制**(不是 move,避免中途失败丢父)到 `remix-fallbacks/{parent_room_id}/v{n}/canonical.glb` 与 `.../layer_manifest.json`
> 3. 更新 `remixes.parent_snapshot_path = 'remix-fallbacks/{parent_room_id}/v{n}/canonical.glb'`(事务内)
> 4. 全部 Remix 写完后再执行 `delete from public.rooms where id = $1`cascade 子表与原 Storage 路径清理)
> 5. 任一步失败 → 整体事务回滚 + Storage 复制产物垃圾回收(异步) + 返回 `PARENT_SNAPSHOT_TRANSFER_FAILED`
>
> 该目录的 RLS / CDN 缓存策略与 `rooms/` 相同(public bucketCDN 可缓存);Web Remix 详情页加载几何时优先看 `remixes.parent_snapshot_path` 是否非空,非空则从 fallback 路径加载,否则从原父 `rooms/.../` 加载。
### 4.2 私有 bucket 目录
```
private/rooms/{room_id}/v{version_no}/
├── source.usdz # 原始 RoomPlan 导出
├── source.roomplan.json # CapturedRoom JSON(含 walls/doors/windows/objects
├── transcode.log # Worker 日志(含失败堆栈)
└── pre_redaction.jpg # 脱敏前原图缩略(仅当 owner 在 App 内勾选"保留备份"
```
### 4.3 命名约定
- `room_id` 用 UUID v4 的 32 位 hex(无 `-`),路径更短:`rooms/8f1c.../v1/...`
- `version_no` 从 1 递增;删除版本不复用号
- 公开 bucket 对象走 CDNURL 形如 `https://{project}.supabase.co/storage/v1/object/public/rooms/{room_id}/v{n}/canonical.glb`
- 私有 bucket 由 Edge Function 签发 presigned URLTTL 默认 60 s(上传)/ 600 s(下载)
- 所有写入路径在 Worker 端走 `{room_id}/v{n}/.tmp/` 暂存目录,转码完成后 `rename` 到正式路径,避免半成品被读到
---
## 5. `layer_manifest.json` JSON SchemaDraft 2020-12
`layer_manifest.json` 是 Web 端的**入口文件**:拉一次就能拿到 4 层结构、每层节点 ID、家具语义、材质槽位,再按需 lazy 加载 `canonical.glb` 的子树。
### 5.1 设计约束
| 约束 | 说明 |
|------|------|
| **4 层固定** | `layers` 必须正好包含 `walls / floor / furniture / materials` 4 个键,缺一则 manifest 无效 |
| **节点指向 .glb** | `mesh_node_ids[]` 中每个 ID 必须能在 `canonical.glb` 中通过 `node.name == id` 找到 |
| **材质是逻辑层** | `materials.slots[]` 引用其它三层中的 `target_mesh_id`,不持有几何 |
| **家具语义对齐 RoomPlan** | `semantic_class` 取值限定在 RoomPlan 16 类(见 §5.3 |
| **bbox 单位** | 米(meters);坐标系右手、+Y 向上(与 glTF 一致;RoomPlan 原始 +Z 向上由 Worker 转换) |
### 5.2 JSON Schema 定义
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://crowdroom.app/schemas/layer_manifest.v1.json",
"title": "CrowdRoom Layer Manifest",
"type": "object",
"required": ["schema_version", "room_id", "version_no", "glb_uri", "layers"],
"additionalProperties": false,
"properties": {
"schema_version": { "const": "1.0.0" },
"room_id": { "type": "string", "pattern": "^[0-9a-f]{32}$" },
"version_no": { "type": "integer", "minimum": 1 },
"glb_uri": { "type": "string", "format": "uri-reference" },
"coordinate_system": {
"type": "object",
"properties": {
"handedness": { "const": "right" },
"up_axis": { "const": "+Y" },
"unit": { "const": "meter" }
},
"required": ["handedness", "up_axis", "unit"]
},
"room_metrics": {
"type": "object",
"description": "从 RoomPlan JSON 汇总",
"properties": {
"floor_area_m2": { "type": "number", "minimum": 0 },
"ceiling_height_m": { "type": "number", "minimum": 0 },
"wall_count": { "type": "integer", "minimum": 0 },
"door_count": { "type": "integer", "minimum": 0 },
"window_count": { "type": "integer", "minimum": 0 },
"furniture_count": { "type": "integer", "minimum": 0 }
}
},
"layers": {
"type": "object",
"required": ["walls", "floor", "furniture", "materials"],
"additionalProperties": false,
"properties": {
"walls": { "$ref": "#/$defs/structuralLayer" },
"floor": { "$ref": "#/$defs/structuralLayer" },
"furniture": { "$ref": "#/$defs/furnitureLayer" },
"materials": { "$ref": "#/$defs/materialsLayer" }
}
}
},
"$defs": {
"bbox": {
"type": "object",
"required": ["min", "max"],
"properties": {
"min": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
"max": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 }
}
},
"obb": {
"type": "object",
"required": ["center", "extent", "quat"],
"properties": {
"center": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
"extent": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
"quat": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4,
"description": "[w,x,y,z]" }
}
},
"structuralLayer": {
"type": "object",
"required": ["mesh_node_ids", "bbox", "category", "replaceable"],
"properties": {
"mesh_node_ids": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"bbox": { "$ref": "#/$defs/bbox" },
"category": { "type": "string", "enum": ["wall", "floor", "ceiling"] },
"replaceable": { "type": "boolean", "description": "材质是否可换;几何不可换" }
}
},
"furnitureLayer": {
"type": "object",
"required": ["items"],
"properties": {
"items": {
"type": "array",
"items": { "$ref": "#/$defs/furnitureItem" }
}
}
},
"furnitureItem": {
"type": "object",
"required": ["item_id", "mesh_node_ids", "obb", "anchor_point", "semantic_class", "replaceable"],
"properties": {
"item_id": { "type": "string" },
"mesh_node_ids": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"obb": { "$ref": "#/$defs/obb" },
"anchor_point": { "type": "array", "items": { "type": "number" },
"minItems": 3, "maxItems": 3,
"description": "替换家具时新模型应贴合的世界点(通常是 OBB 底面中心)" },
"semantic_class": {
"type": "string",
"enum": ["storage", "refrigerator", "stove", "bed", "sink", "washer_dryer",
"toilet", "bathtub", "oven", "dishwasher", "table", "sofa",
"chair", "fireplace", "television", "stairs"]
},
"replaceable": { "type": "boolean" },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
}
},
"materialsLayer": {
"type": "object",
"required": ["slots"],
"properties": {
"slots": {
"type": "array",
"items": { "$ref": "#/$defs/materialSlot" }
}
}
},
"materialSlot": {
"type": "object",
"required": ["slot_id", "target_mesh_id", "uv_channel", "pbr_defaults"],
"properties": {
"slot_id": { "type": "string" },
"target_mesh_id": { "type": "string",
"description": "必须出现在 walls/floor/furniture 的 mesh_node_ids 中" },
"uv_channel": { "type": "integer", "minimum": 0, "maximum": 3 },
"pbr_defaults": {
"type": "object",
"required": ["base_color", "roughness", "metallic"],
"properties": {
"base_color": { "type": "array", "items": { "type": "number" },
"minItems": 4, "maxItems": 4,
"description": "RGBA 0-1" },
"base_color_tex": { "type": "string", "description": "可选贴图 URI" },
"normal_tex": { "type": "string" },
"roughness": { "type": "number", "minimum": 0, "maximum": 1 },
"metallic": { "type": "number", "minimum": 0, "maximum": 1 },
"ao_tex": { "type": "string" }
}
},
"replaceable": { "type": "boolean", "default": true }
}
}
}
}
```
### 5.3 完整示例(≥50 行)
```json
{
"schema_version": "1.0.0",
"room_id": "8f1c2a4d6b9e4f0e8a7c3d2b1f5e9a0c",
"version_no": 1,
"glb_uri": "rooms/8f1c2a4d6b9e4f0e8a7c3d2b1f5e9a0c/v1/canonical.glb",
"coordinate_system": { "handedness": "right", "up_axis": "+Y", "unit": "meter" },
"room_metrics": {
"floor_area_m2": 18.4,
"ceiling_height_m": 2.72,
"wall_count": 5,
"door_count": 1,
"window_count": 2,
"furniture_count": 6
},
"layers": {
"walls": {
"mesh_node_ids": ["wall_0", "wall_1", "wall_2", "wall_3", "wall_4"],
"bbox": { "min": [-2.8, 0.0, -3.1], "max": [2.8, 2.72, 3.1] },
"category": "wall",
"replaceable": true
},
"floor": {
"mesh_node_ids": ["floor_0"],
"bbox": { "min": [-2.8, 0.0, -3.1], "max": [2.8, 0.02, 3.1] },
"category": "floor",
"replaceable": true
},
"furniture": {
"items": [
{
"item_id": "bed_001",
"mesh_node_ids": ["furn_bed_001"],
"obb": {
"center": [-0.5, 0.30, -1.4],
"extent": [2.00, 0.60, 1.50],
"quat": [1.0, 0.0, 0.0, 0.0]
},
"anchor_point": [-0.5, 0.0, -1.4],
"semantic_class": "bed",
"replaceable": true,
"confidence": 0.94
},
{
"item_id": "table_001",
"mesh_node_ids": ["furn_table_001"],
"obb": {
"center": [1.2, 0.38, 0.5],
"extent": [1.20, 0.04, 0.60],
"quat": [0.924, 0.0, 0.383, 0.0]
},
"anchor_point": [1.2, 0.0, 0.5],
"semantic_class": "table",
"replaceable": true,
"confidence": 0.88
},
{
"item_id": "tv_001",
"mesh_node_ids": ["furn_tv_001"],
"obb": {
"center": [0.0, 1.20, -2.95],
"extent": [1.10, 0.65, 0.08],
"quat": [1.0, 0.0, 0.0, 0.0]
},
"anchor_point": [0.0, 1.20, -2.95],
"semantic_class": "television",
"replaceable": true,
"confidence": 0.91
}
]
},
"materials": {
"slots": [
{
"slot_id": "mat_wall_paint",
"target_mesh_id": "wall_0",
"uv_channel": 0,
"pbr_defaults": {
"base_color": [0.93, 0.91, 0.88, 1.0],
"roughness": 0.85,
"metallic": 0.0
},
"replaceable": true
},
{
"slot_id": "mat_floor_wood",
"target_mesh_id": "floor_0",
"uv_channel": 0,
"pbr_defaults": {
"base_color": [0.55, 0.40, 0.28, 1.0],
"base_color_tex": "rooms/8f1c.../v1/tex/floor_wood_diffuse.webp",
"normal_tex": "rooms/8f1c.../v1/tex/floor_wood_normal.webp",
"roughness": 0.62,
"metallic": 0.0
},
"replaceable": true
},
{
"slot_id": "mat_bed_fabric",
"target_mesh_id": "furn_bed_001",
"uv_channel": 0,
"pbr_defaults": {
"base_color": [0.95, 0.95, 0.95, 1.0],
"roughness": 0.78,
"metallic": 0.0
},
"replaceable": true
}
]
}
}
}
```
---
## 6. CrowdRoom 4 层 ↔ PRISM L1L4 映射
参考 [`plans/PRISM/03_data_schema.md`](../PRISM/03_data_schema.md) §3.2 的 `MemoryLevel`。CrowdRoom 是 PRISM 的**降维投影**:保留消费级展示需要的几何 + 语义,丢掉机器人专用的稠密/时序/翻案字段。
| CrowdRoom 层 | PRISM 对应 | 降维说明(保留什么) | 丢弃的信息 |
|--------------|-----------|----------------------|------------|
| `walls` | **L2 度量**`category='wall'` 的 SpatialNode `polygon_2d + bbox_3d` | 仅保留墙面 mesh + bbox + 可否换材质标记 | TSDF/OctoMap 稠密体素、`no_update_zone`(镜面)、墙面厚度的多次测量历史 |
| `floor` | **L2 度量**`category='floor'` 节点的 `mesh_uri` | 单一地面 mesh + bbox | 高程网格、3DGS 高斯、地面材质多视角光照 |
| `furniture` | **L4 语义**`category='furniture'` 的 SpatialNode(每件 1 节点) | `item_id / obb / anchor_point / semantic_class / confidence` | `keyframe_evidence`per-frame 翻案)、`clip_embedding`512D 向量)、`attributes.mobile/fragile/state``parent_room``SpatialEdge` 关系(on/under/next_to |
| `materials` | **L4 语义** 节点的 `attributes.material` + L2 mesh 的 UV/texture | PBR 槽位(base_color/roughness/metallic + 贴图 URI | 偏振材质属性、物理摩擦/密度(PRISM `material_props.json`)、各向异性反射 |
| 〔无对应〕 | **L1 感知缓冲** | — | CrowdRoom 不保留 keyframe RGB/Depth 流(隐私 + 体积,整馆 ~1GB) |
| 〔无对应〕 | **L3 拓扑** | — | CrowdRoom 单房间作品,无房间间拓扑边;楼层拼接划在 P2 |
**关键退化**CrowdRoom 的"4 层"是**展示导向**,不是"L1/L2/L3/L4"四层。同样叫"layer",但语义不同——前者是 UI 复选框,后者是认知层级。本表确保两套术语在交界处不冲突。
---
## 7. 隐私脱敏元数据补充
[`redactions`](#35-redactions隐私脱敏) 表配合 [`00_overview.md`](00_overview.md) §8 RK-4 的承诺,落地以下行为:
| `kind` | 触发方 | 典型 `region` | 应用阶段 |
|--------|--------|---------------|----------|
| `face` | iOS 端 Vision 自动检测 | 2D 贴图坐标 bbox(`space=texture`) | 上传前端侧模糊,服务端冗余存 region 便于举报复核 |
| `mirror` | RoomPlan 法向 + 反射强度启发式 | 3D world OBB`space=world` | Web 端渲染时叠加"反射区域"图标提醒 |
| `logo` | iOS Vision 文字/品牌检测 | 2D 贴图 bbox | 上传前端侧模糊 |
| `plate` | 同 face,针对车牌/身份证 | 2D 贴图 bbox | 同 face |
| `user_marked` | App 内"涂抹敏感区"工具 | 3D world OBB 或 2D bbox | 在 manifest 中标 `obscured_node_ids[]`Web 端整节点替换为占位 |
**RLS 选择**`redactions` 表只有 owner 自己可读全量(policy `redactions_owner_only`)。第三方只能间接看到"该区域有内容被脱敏"manifest 内嵌的 `obscured_node_ids`),看不到 region 坐标,避免攻击者通过坐标反推真实人脸/证件位置。
---
## 8. 本章小结与对外契约
| 契约 | 给谁 | 一句话 |
|------|------|--------|
| **9 表(含 `public.users`+ 完整 RLS** | iOS / Web / Worker | 客户端永远用 anon JWT 走 PostgRESTWorker 用 service_role 绕过 RLS 写 `room_versions.status` |
| **Storage 双 bucket 模型** | iOS / Worker | `public/rooms/`CDN 可缓存)+ `private/rooms/`(原始 .usdz/JSON 不直出) |
| **`layer_manifest.json` v1.0** | Worker(生产)/ Web(消费) | 4 层固定、家具 16 类、材质走 slots;任何字段缺失视为 manifest 无效(错误码 `LAYER_MANIFEST_INVALID` |
| **PRISM 兼容** | 未来"用户贡献先验"链路 | CrowdRoom 节点可被映射回 PRISM L2/L4 SpatialNode,但不携带 L1 keyframe 与 L3 拓扑 |
| **隐私默认开** | iOS App | `redactions` 至少包含 `face` 自动检测条目(即便为 0 个面孔,也应写一条 `kind=face, region={"empty":true}` 表示已扫描) |
下一章 [`02_api_contract.md`](02_api_contract.md) 在此 schema 上定义端点、Edge Function 与转码流水。
---
**章节版本**v0.1 · 草案
**关键收获**CrowdRoom 落到 Supabase 上 = 9 张表 + 2 个 Storage bucket + 1 份 `layer_manifest.json` Schema;与 PRISM 的关系是"展示层 4 层 ≈ L2/L4 降维投影"。