989 lines
44 KiB
Markdown
989 lines
44 KiB
Markdown
---
|
||
title: "CrowdRoom · 数据模型与 Storage 规范(v0.2)"
|
||
date: 2026-05-20
|
||
draft: false
|
||
tags: ["CrowdRoom", "众包", "3D 重建", "隐私", "Web", "数据结构"]
|
||
categories: ["CrowdRoom"]
|
||
---
|
||
|
||
# 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 降维投影"。 |