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

44 KiB
Raw Permalink Blame History

title, date, draft, tags, categories
title date draft tags categories
CrowdRoom · 数据模型与 Storage 规范(v0.2 2026-05-20 false
CrowdRoom
众包
3D 重建
隐私
Web
数据结构
CrowdRoom

CrowdRoom · 数据模型与 Storage 规范(v0.2

版本v0.22026-05-19 v0.2 修订:回写 G-1remixes.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.json Schema,以及与 plans/PRISM/03_data_schema.md L1L4 的映射。

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 会把审计查询变成全表扫;独立表可索引 kindapplied_at
D3 4 层 ID 是否固定 固定为 walls / floor / furniture / materials,写死在 enum 减少前端切换器的字符串拼接错误;任何"未知层"在 Worker 阶段就拒收,避免脏数据进入 Web
D4 材质层的存储形态 逻辑层materialslayer_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 是 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 §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 usersauth.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-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

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-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 §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_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

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 → remixesrestrict 语义在 v0.2 起由 Edge Function room-delete-with-snapshot02_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_FAILED02_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_versions cascadeassets 是平台资产无用户归属)。

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(影子表 usersroomsroom_versionsremixescommentslikes)。示例(以 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-export02_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-exportE-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 Function room-delete-with-snapshot / 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_pathmanifest_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 = $1cascade 子表与原 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 定义

{
  "$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 L1L4 映射

参考 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_evidenceper-frame 翻案)、clip_embedding512D 向量)、attributes.mobile/fragile/stateparent_roomSpatialEdge 关系(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 贴图坐标 bboxspace=texture 上传前端侧模糊,服务端冗余存 region 便于举报复核
mirror RoomPlan 法向 + 反射强度启发式 3D world OBBspace=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 在此 schema 上定义端点、Edge Function 与转码流水。


章节版本v0.1 · 草案 关键收获CrowdRoom 落到 Supabase 上 = 9 张表 + 2 个 Storage bucket + 1 份 layer_manifest.json Schema;与 PRISM 的关系是"展示层 4 层 ≈ L2/L4 降维投影"。