Files
worldmodel/plans/CrowdRoom/11_asset_library.md
T

1239 lines
65 KiB
Markdown
Raw 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.
# CrowdRoom · 资产库(Asset Library)模块设计(v0.2
> **版本**v0.22026-05-19)· 子任务 10 产出
> **定位**:把 [`01_data_schema.md`](01_data_schema.md) §3.9 中**仅作为"平台 CC0 公共素材池"**存在的 `public.assets` 表,**升级**为一个支持「平台爬取导入 + 用户 UGC 上传 + Remix 衍生」三源流的资产库子系统;同时**逐条兑现** [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §15 移交的 **AL-1 ~ AL-10** 硬契约。
>
> **本文档不修改任何 v0.2 既有架构**——所有对 [`01_data_schema.md`](01_data_schema.md) / [`02_api_contract.md`](02_api_contract.md) / [`04_web_app_plan.md`](04_web_app_plan.md) / [`10_governance.md`](10_governance.md) 的扩展统一以「**建议增量**」标注;落地需在下一版(v0.3)对应文档中追认。
>
> 文档语言:简体中文;DDL / TS / JSON 字段命名:英文(与既有契约对齐)。
---
## 0. 阅读导航
| 你的角色 | 重点章节 |
|---------|---------|
| 后端 / DBA | §2 增量 ALTER · §6 端点表 |
| Edge Function / Worker | §4 平台导入管线 · §5.2 UGC 预处理 worker · §5.3 自动初筛 |
| Web 前端(AssetPicker | §3 Storage 路径 · §6 E-A3 检索 · §7 排序公式 |
| Web 前端(上传向导) | §5.1 5 步向导 · §6 E-A1/E-A2 |
| 运营 / 审核员 | §5.3 审核工作台 · §8 创作者信任分 · §9 协议 |
| 法务 | §9 版权 · §10 收藏与署名 |
| QA | §14 M1 验收 · §15 风险 |
| Reviewer(一眼对账) | §2.6 AL 映射表 · §13 关系矩阵 · §14.2 AL 对照表 |
---
## 1. 资产库定位与产品形态
### 1.1 一句话定义
> **"CrowdRoom 资产库 = 平台 CC0 公共资产(Quaternius / Poly Haven / ambientCG 等爬取/导入)+ 用户上传自制资产(UGC,CC0 或 CC-BY 二选一)+ 资产 Remix 衍生(用户基于已有资产二次创作)。"**
它对应 [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §4 AssetPicker 的**唯一上游数据源**——AssetPicker 拉的所有家具/材质卡片均来自本资产库;同时把 [`10_governance.md`](10_governance.md) §4 P-W-4 决策的「平台资产 CC0 白名单」从一张孤立的内部素材表,扩展为一个有用户参与、有创作者飞轮的子社区。
### 1.2 三类资产对照
| # | 类型 | 来源 | 协议(MVP) | 准入流程 | 默认 `trust_score` | AssetPicker 排序权重 |
|---|------|------|-----------|---------|-------------------|---------------------|
| **T1 平台 CC0** | `source_type='platform'` | 运营批量爬取/导入(Quaternius、Poly Haven、ambientCG、Sketchfab CC0 子集) | 一律 CC0 | 自动入库 `review_status='approved'` | **80** | 高(默认头部) |
| **T2 用户上传** | `source_type='user_upload'` | 普通用户走 §5 上传向导 | CC0(MVP 强制);CC-BY 见 v0.5 ROADMAP | 自动初筛 + 人工 24-72 h 审 | **30**(新手)/ **50**Trusted Creator 跳过人工) | 中(按 §7 综合公式) |
| **T3 Remix 衍生** | `source_type='remix_of'` + `parent_asset_id` | 用户 fork 已有资产 → 改 PBR / mesh → 发布 | 继承父协议(CC0 → 可选;CC-BY → 必须 CC-BY | 同 T2 | 继承父 × 0.8 | 中低(避免刷量) |
> **MVP 一致性声明**(与 [`10_governance.md`](10_governance.md) §4 P-W-4 对齐):
>
> - MVP 阶段 T1 全 CC0T2 / T3 用户上传**强制 CC0**[`04_web_app_plan.md`](04_web_app_plan.md) §5.4 顶部「仅显示 CC0」开关**始终为 ON 且不可关**,等同于"全库即 CC0"
> - v0.5DAU > 5 000)后才开放 CC-BY 4.0 上传选项;CC-BY-SA / CC-BY-NC / 付费素材**永不接**(避免 Remix 树协议污染)
> - 本资产库**不引入 CrowdRoom 平台默认的 CC BY-NC 协议**——资产是给所有 Remix 重用的"乐高积木",必须 NC-free
### 1.3 与 v0.2 既有设计的边界
| 边界 | v0.2 既有 | 本文档扩展 |
|------|-----------|----------|
| `public.assets` 表行数 | 1 个 Storage 文件 + 9 列字段 | 仍是 1 张表,仅 **ALTER 追加 13 列**(§2 |
| 资产上传能力 | 无(仅运营 service_role 写)| 新增 8 个 E-A1~E-A8 端点(§6 |
| AssetPicker 检索维度 | `kind / semantic_class / tags` | 追加 `style_tag / volume_m3 / dominant_color / source_type / license` |
| 治理流程 | 仅"内部抽查"[`10_governance.md`](10_governance.md) §4 P-W-4 步骤 3 | 复用 [`10_governance.md`](10_governance.md) §2.1 既有 NSFW + 敏感词自动审核流,新增「资产审核队列」分支 |
---
## 2. 数据模型增强(AL-1 / AL-2 / AL-4 / AL-8 落地)
### 2.1 设计原则
1. **不破坏 v0.2** — [`01_data_schema.md`](01_data_schema.md) §3.9 既有 9 列字段(`id / kind / name / semantic_class / glb_path / pbr / thumbnail_path / license / source_url / tags / created_at`**保持不动**;本节仅做 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` 增量
2. **AL 契约逐字段落地** — 每一列都对应 AL-1 ~ AL-10 中的某一条(见 §2.6 映射表)
3. **三张支撑表**`asset_review_queue`(审核工单)、`asset_reports`(举报)、`asset_collections`(用户收藏/官方风格集)拆为独立表,避免 `assets` 表膨胀
### 2.2 `assets` 表增量 ALTER
```sql
-- 建议增量:可在 Supabase SQL Editor 中按顺序执行
-- 与 01_data_schema.md §3.9 既有字段完全兼容,不修改既有列
ALTER TABLE public.assets
-- ============= AL-1: 朝向 & 包围盒(家具替换对齐契约) =============
ADD COLUMN IF NOT EXISTS anchor_local jsonb NOT NULL DEFAULT '{}'::jsonb,
-- 结构:{"position":[x,y,z],"forward_axis":"+Z","up_axis":"+Y"}
-- position 单位米;forward_axis / up_axis ∈ {+X,-X,+Y,-Y,+Z,-Z}
ADD COLUMN IF NOT EXISTS bbox_local jsonb NOT NULL DEFAULT '{}'::jsonb,
-- {"min":[x,y,z],"max":[x,y,z]} 单位米;坐标系右手 +Y 向上(与 glTF 一致)
-- ============= AL-4: 体量 & 主色(检索维度) =============
ADD COLUMN IF NOT EXISTS volume_m3 numeric(10,3) NOT NULL DEFAULT 0,
-- 由 bbox_local 计算的物化列(worker 写入)
ADD COLUMN IF NOT EXISTS dominant_color text,
-- '#RRGGBB'LAB 平均后量化);material 类资产用 base_color
-- furniture 类资产用 thumb_512 的中心 256×256 区域 K-Means(K=3) 最大簇
-- ============= AL-3: 风格标签 =============
ADD COLUMN IF NOT EXISTS style_tag text,
-- enum: 'nordic' | 'industrial' | 'chinese' | 'minimal'
-- | 'retro' | 'wabi_sabi' | 'bauhaus'
-- 1 件资产仅 1 个主风格;多风格通过 tags[] 兼顾
-- ============= AL-7: 协议(v0.2 已有 license,此处扩展约束位置) =============
-- license 列已存在;仅追加 CHECK 约束(见 §2.3)
-- ============= 三源流溯源 =============
ADD COLUMN IF NOT EXISTS source_type text NOT NULL DEFAULT 'platform',
-- enum: 'platform' | 'user_upload' | 'remix_of'
ADD COLUMN IF NOT EXISTS parent_asset_id uuid REFERENCES public.assets(id),
-- 仅 source_type='remix_of' 时非空
ADD COLUMN IF NOT EXISTS uploader_id uuid REFERENCES public.users(id),
-- 平台资产为 NULLuser_upload / remix_of 必填
-- ============= 审核状态机 =============
ADD COLUMN IF NOT EXISTS reviewer_id uuid REFERENCES public.users(id),
ADD COLUMN IF NOT EXISTS review_status text NOT NULL DEFAULT 'approved',
-- enum: 'pending' | 'approved' | 'rejected' | 'withdrawn'
-- 平台 import 默认 'approved';用户上传默认 'pending'
ADD COLUMN IF NOT EXISTS rejection_reason text,
-- 仅 review_status='rejected' 时非空;通过站内信通知作者
-- ============= 信任分 & 使用统计 =============
ADD COLUMN IF NOT EXISTS trust_score smallint NOT NULL DEFAULT 50
CHECK (trust_score BETWEEN 0 AND 100),
-- 影响 §7 AssetPicker 排序权重;详见 §8
ADD COLUMN IF NOT EXISTS download_count int NOT NULL DEFAULT 0,
-- E-A3 详情命中 +1
ADD COLUMN IF NOT EXISTS use_count int NOT NULL DEFAULT 0,
-- 被 remix_overlay 引用次数(trigger 自增,见 §2.5
-- ============= 标签(已存在 tags,此处补充注释) =============
-- tags text[]v0.2 既有;本文档要求 worker 自动从 CLIP/缩略图提取 ≥ 3 条
-- 例如 ["wood","mid_century","oak","two_seater"]
-- ============= 软删(与 v0.2 G-9 对齐) =============
ADD COLUMN IF NOT EXISTS deleted_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
```
### 2.3 license 列 CHECK 约束(AL-7 落地)
```sql
-- 建议增量:把 v0.2 既有 `license text not null default 'CC0'` 收紧为白名单
ALTER TABLE public.assets
ADD CONSTRAINT assets_license_whitelist
CHECK (license IN ('CC0', 'CC-BY-4.0'));
-- 'CC-BY-SA' / 'CC-BY-NC' / 'commercial' 永不允许(与 P-W-4 对齐)
```
### 2.4 索引(AL-4 / AL-10 落地:复合检索 < 200 ms
```sql
-- tags 数组 GINAL-4 强制要求;v0.2 §3.9 已存在 assets_tags 索引,此处幂等重建)
CREATE INDEX IF NOT EXISTS assets_tags_gin
ON public.assets USING gin (tags);
-- 单维 btree
CREATE INDEX IF NOT EXISTS assets_semantic_class_btree
ON public.assets (semantic_class);
CREATE INDEX IF NOT EXISTS assets_volume_btree
ON public.assets (volume_m3);
CREATE INDEX IF NOT EXISTS assets_style_btree
ON public.assets (style_tag);
-- 复合:source_type × review_statusAssetPicker 默认仅拉 approved
CREATE INDEX IF NOT EXISTS assets_source_review_btree
ON public.assets (source_type, review_status)
WHERE deleted_at IS NULL;
-- 信任分排序(部分索引仅覆盖 approved)
CREATE INDEX IF NOT EXISTS assets_trust_desc
ON public.assets (trust_score DESC, use_count DESC)
WHERE review_status = 'approved' AND deleted_at IS NULL;
-- name 模糊搜索(与 rooms 一致使用 pg_trgm
CREATE INDEX IF NOT EXISTS assets_name_trgm
ON public.assets USING gin (name gin_trgm_ops);
```
### 2.5 RLS 补丁(覆盖既有 §3.9 policy
```sql
-- v0.2 §3.9 只有 assets_select_all 一条 policy;本文档扩展为 4 条
ALTER TABLE public.assets ENABLE ROW LEVEL SECURITY;
-- SELECT:所有人可见 approved 的;owner 可见自己的 pending/rejected/withdrawn
DROP POLICY IF EXISTS assets_select_all ON public.assets;
CREATE POLICY assets_select_public ON public.assets FOR SELECT USING (
deleted_at IS NULL
AND (
review_status = 'approved'
OR uploader_id = auth.uid()
)
);
-- INSERT:登录用户可插入 pendinguploader_id 必须 = auth.uid()
CREATE POLICY assets_insert_user ON public.assets FOR INSERT WITH CHECK (
uploader_id = auth.uid()
AND source_type IN ('user_upload', 'remix_of')
AND review_status = 'pending'
);
-- UPDATE:仅 owner 在 pending 期可改元数据;approved 后只读(service_role 走 §5.3 审核工具走 RPC
CREATE POLICY assets_update_owner_pending ON public.assets FOR UPDATE USING (
uploader_id = auth.uid() AND review_status = 'pending'
) WITH CHECK (
uploader_id = auth.uid() AND review_status = 'pending'
);
-- DELETEowner 可在 pending 时撤回(软删);approved 后走 E-A8 走 service_role
CREATE POLICY assets_delete_owner_pending ON public.assets FOR DELETE USING (
uploader_id = auth.uid() AND review_status = 'pending'
);
-- use_count 自增触发器(被 remix_overlay 引用时 +1
CREATE FUNCTION bump_asset_use_count() RETURNS trigger AS $$
DECLARE
op_record jsonb;
ref_asset_id uuid;
BEGIN
-- 扫描 new.overlay->ops[] 中 asset_id 字段
FOR op_record IN SELECT * FROM jsonb_array_elements(NEW.overlay->'ops')
LOOP
ref_asset_id := (op_record->>'asset_id')::uuid;
IF ref_asset_id IS NOT NULL THEN
UPDATE public.assets
SET use_count = use_count + 1, updated_at = now()
WHERE id = ref_asset_id;
END IF;
END LOOP;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER remixes_bump_assets_use
AFTER INSERT ON public.remixes
FOR EACH ROW EXECUTE FUNCTION bump_asset_use_count();
```
### 2.6 AL-1 ~ AL-10 字段映射表(**对账核心**)
| 契约 ID | AL 要求一句话 | 落地字段 / 索引 | 文档章节 |
|---------|--------------|----------------|---------|
| **AL-1** | 家具资产必须有 anchor + forward/up + bbox | `anchor_local jsonb``bbox_local jsonb`(§2.2 | §2.2 / §4 / §5.2 |
| **AL-2** | 公开资产 `semantic_class` 非空率 ≥ 99% | `semantic_class`v0.2 既有)+ `assets_semantic_class_btree`;§5.1.3 上传向导强制选 + §4 worker import 时 fallback `unknown` 标 reject | §2.4 / §5.1 |
| **AL-3** | `tags[]` 必含风格 tag(7 类),每件至少 1 个 | `style_tag text`(§2.2 enum 7 类)+ `assets_style_btree`worker 自动从 CLIP 推断 + 上传向导默认必填 | §2.2 / §4 / §5.1 |
| **AL-4** | 物化 `volume_m3` + `dominant_color`GIN tags | `volume_m3 numeric` + `dominant_color text` + `assets_tags_gin` + `assets_volume_btree`(§2.2 / §2.4 | §2.2 / §6 E-A3 |
| **AL-5** | .glb ≤ 1 MB(家具)/ ≤ 200 KB(材质贴图集) | §3 Storage 路径硬约束 + §5.2 worker 步骤 W-5 拒收超标 | §3 / §5.2 |
| **AL-6** | 必须 128×128 webp 缩略图 | `thumb_128.webp`(§3 路径) + §5.2 worker 步骤 W-9 自动生成 | §3 / §5.2 |
| **AL-7** | `license` 二选一 `CC0 | CC-BY` | `license` CHECK 白名单(§2.3+ §9 协议管理 | §2.3 / §9 |
| **AL-8** | 材质 `pbr` jsonb 必含 base_color_tex/normal_tex/roughness/metallic | `pbr jsonb`v0.2 既有)+ §5.2 worker 步骤 W-7 schema 校验 | §5.2 W-7 |
| **AL-9** | asset_id ↔ glb_path 不可重定向 | §3 路径硬规则 `public/assets/{asset_id}/model.glb`asset_id 永不复用、glb_path 不允许 UPDATEpolicy 限制) | §3 |
| **AL-10** | `GET /assets?bbox_filter=&style=&semantic=` 复合检索 | E-A3 端点(§6) + §2.4 复合索引 | §6 / §7 |
> **结论**AL-1 ~ AL-10 **每一条都对应到至少一个具体字段/索引/Worker 步骤/端点****10/10 闭环**。
### 2.7 支撑表 DDL
#### 2.7.1 `asset_review_queue`(审核工单)
```sql
CREATE TYPE asset_review_action AS ENUM (
'approve', 'approve_with_edits', 'reject', 'escalate'
);
CREATE TABLE public.asset_review_queue (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL REFERENCES public.assets(id) ON DELETE CASCADE,
enqueued_at timestamptz NOT NULL DEFAULT now(),
priority smallint NOT NULL DEFAULT 5,
-- 1=最高(举报触发的复审)/ 5=新上传 / 9=平台 import 抽查
auto_signals jsonb NOT NULL DEFAULT '{}'::jsonb,
-- {nsfw_score: 0.2, triangle_count: 38912, pii_detected: false, ...}
reviewer_id uuid REFERENCES public.users(id),
reviewed_at timestamptz,
action asset_review_action,
notes text,
sla_deadline timestamptz NOT NULL DEFAULT (now() + INTERVAL '72 hours')
);
CREATE INDEX queue_pending ON public.asset_review_queue (priority, enqueued_at)
WHERE reviewed_at IS NULL;
CREATE INDEX queue_asset ON public.asset_review_queue (asset_id);
ALTER TABLE public.asset_review_queue ENABLE ROW LEVEL SECURITY;
-- 仅有 'reviewer' role 或 service_role 可访问(service_role 绕过 RLS
-- reviewer role 通过 auth.jwt() ->> 'role' 区分
CREATE POLICY queue_reviewer_only ON public.asset_review_queue FOR ALL
USING (auth.jwt() ->> 'role' = 'reviewer');
```
#### 2.7.2 `asset_reports`(侵权 / 违规举报)
```sql
CREATE TABLE public.asset_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL REFERENCES public.assets(id) ON DELETE CASCADE,
reporter_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
reason text NOT NULL
CHECK (reason IN ('copyright', 'nsfw', 'low_quality', 'duplicate',
'wrong_metadata', 'malicious', 'other')),
detail text,
created_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz,
resolution text
CHECK (resolution IN ('confirmed_violation', 'false_report', 'duplicate_report')),
UNIQUE (asset_id, reporter_id) -- 同人对同资产 24h 内只能举报一次(业务层强制)
);
CREATE INDEX reports_asset ON public.asset_reports (asset_id);
CREATE INDEX reports_pending ON public.asset_reports (created_at)
WHERE resolved_at IS NULL;
ALTER TABLE public.asset_reports ENABLE ROW LEVEL SECURITY;
CREATE POLICY reports_insert_user ON public.asset_reports FOR INSERT
WITH CHECK (reporter_id = auth.uid());
CREATE POLICY reports_select_owner ON public.asset_reports FOR SELECT
USING (reporter_id = auth.uid() OR auth.jwt() ->> 'role' = 'reviewer');
```
> **联动**:累计 ≥ 3 条独立 `asset_reports` 行未 resolved 时,触发 `assets.review_status` 自动改为 `pending` + 工单进 `asset_review_queue` 重审(priority=1),与 [`10_governance.md`](10_governance.md) §2.2「3 独立举报自动隐藏」对齐。
#### 2.7.3 `asset_collections`(用户收藏夹 / 官方风格集)
```sql
CREATE TABLE public.asset_collections (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 80),
description text,
is_public boolean NOT NULL DEFAULT false,
is_official boolean NOT NULL DEFAULT false,
-- true = 审核员打包的「官方风格集」,发现页可展示
asset_ids uuid[] NOT NULL DEFAULT ARRAY[]::uuid[],
cover_color text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX collections_owner ON public.asset_collections (owner_id);
CREATE INDEX collections_official ON public.asset_collections (is_official)
WHERE is_official = true;
ALTER TABLE public.asset_collections ENABLE ROW LEVEL SECURITY;
CREATE POLICY collections_select ON public.asset_collections FOR SELECT
USING (is_public OR is_official OR owner_id = auth.uid());
CREATE POLICY collections_write_owner ON public.asset_collections FOR ALL
USING (owner_id = auth.uid()) WITH CHECK (owner_id = auth.uid());
-- is_official 仅 service_role 可写
```
#### 2.7.4 `creator_profiles`(创作者信任分扩展)
```sql
-- 复用 public.users,新增创作者维度独立列
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS creator_trust_score smallint NOT NULL DEFAULT 50
CHECK (creator_trust_score BETWEEN -100 AND 100),
ADD COLUMN IF NOT EXISTS creator_badge text
CHECK (creator_badge IN ('trusted', 'verified', 'pro')),
ADD COLUMN IF NOT EXISTS creator_uploaded_count int NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS creator_use_count_total int NOT NULL DEFAULT 0;
CREATE INDEX users_creator_trust ON public.users (creator_trust_score DESC)
WHERE deleted_at IS NULL AND creator_uploaded_count > 0;
```
### 2.8 字段总览
| 项 | 数量 |
|----|------|
| 新增 `assets` 列 | 13 列 |
| 新增 `users` 列(创作者扩展) | 4 列 |
| 新增表 | 3 张(`asset_review_queue``asset_reports``asset_collections` |
| 新增索引 | 6`assets`+ 5(支撑表)+ 1users 创作者)= 12 个 |
| 新增 trigger | 2 个(`bump_asset_use_count` + `assets_glb_path_lock`,后者见 §3.3 |
| 新增 RLS policy | 4`assets`+ 7(支撑表)= 11 条 |
---
## 3. Storage 目录与命名约定(AL-5 / AL-6 / AL-9 落地)
### 3.1 公开 bucket 目录扩展
```
public/assets/{asset_id}/ # asset_id = uuid v4 32 位 hex(无 -
├── model.glb # 主几何,KTX2 + Meshopt 压缩
│ # 家具 ≤ 1 MB;材质贴图集 ≤ 200 KBAL-5
├── thumb_128.webp # 128×128 列表缩略图(AL-6
├── preview_512.webp # 512×512 详情页预览
├── preview_3d.glb # 可选,更高 LOD 的预览版(≤ 300 KB,仅 trust>70 资产生成)
├── attribution.txt # CC-BY 必须;CC0 可选(保留原作者署名)
└── manifest.json # 资产元信息冗余存储(与 assets 行字段对齐,便于离线检索)
```
> 与 [`01_data_schema.md`](01_data_schema.md) §4 的 `rooms/` / `private/rooms/` 双 bucket 模型并列:本目录复用 v0.2 既有的 `public` bucket(与 `rooms/` 同 bucket,仅顶层 prefix 不同),无需新建 bucket。
### 3.2 命名硬规则(AL-9 落地)
| 规则 | 说明 |
|------|------|
| **R-1 asset_id 永不复用** | 资产被 `withdrawn``id` 不释放;新上传始终拿 `gen_random_uuid()` |
| **R-2 model.glb 路径不可重定向** | `glb_path = 'public/assets/{asset_id}/model.glb'` 写死;trigger `assets_glb_path_lock`(§3.3)拒绝任何 UPDATE 改 `glb_path`service_role 也不放行,避免误操作) |
| **R-3 thumb_128.webp 命名固定** | 不允许 `thumb1.webp` / `cover.webp` 等变体——AssetPicker 拼路径用 |
| **R-4 .tmp 隔离** | Worker 写入时先到 `public/assets/{asset_id}/.tmp/`,转码完成 rename 到正式路径,避免半成品被读 |
| **R-5 attribution.txt UTF-8** | 多个原作者用 `\n` 分隔;格式:`"<creator_name>" <https://url> CC-BY-4.0` |
### 3.3 glb_path 不变性的 SQL 兜底
```sql
-- 建议增量:禁止任何客户端改 glb_path(包括 owner / service_role
CREATE FUNCTION assets_glb_path_immutable() RETURNS trigger AS $$
BEGIN
IF NEW.glb_path IS DISTINCT FROM OLD.glb_path THEN
RAISE EXCEPTION 'glb_path is immutable (AL-9); asset_id=%', OLD.id
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER assets_glb_path_lock
BEFORE UPDATE ON public.assets
FOR EACH ROW EXECUTE FUNCTION assets_glb_path_immutable();
```
---
## 4. 平台资产导入管线(爬虫 / 外部库映射)
### 4.1 来源清单
| 来源 | 协议 | 类型 | MVP 数量目标 | P1 目标 | 备注 |
|------|------|------|-------------|---------|------|
| **Quaternius**github.com/quaternius | CC0 | 家具 / 装饰 | 300 件 | 2 000 件 | `Furniture Kit``Plant Pack` 等 |
| **Poly Haven**polyhaven.com | CC0 | 材质 PBR | 150 套 | 800 套 | base/normal/roughness/metallic/ao 五贴图集 |
| **ambientCG** | CC0 | 材质 PBR | 100 套 | 500 套 | 同上 |
| **Sketchfab CC0 子集** | CC0 | 家具 | 50 件 | 200 件 | **每件单独人工 verify 协议**Sketchfab 标错率高) |
| **Free3D CC0 子集** | CC0 | 家具 | 20 件 | 100 件 | P2 再放量 |
### 4.2 导入 worker 流程图
```mermaid
flowchart TD
Src[Quaternius repo / Poly Haven API / ambientCG zip] --> Fetch[1 Fetch 原始文件]
Fetch --> Detect{2 格式检测}
Detect -- .glb --> Compress
Detect -- .fbx / .obj --> Convert[3 gltf-transform convert]
Convert --> Compress[4 KTX2 + Meshopt 压缩]
Compress --> Limit{5 size check}
Limit -- 超 1 MB --> RetryLOD[6a 再 decimate 到 50k tri]
Limit -- pass --> Bbox[6b 计算 bbox / volume]
RetryLOD --> Bbox
Bbox --> Color[7 提取 dominant_color K-Means]
Color --> Tags[8 CLIP 推断 tags + style_tag]
Tags --> Thumb[9 headless three.js 生成 thumb_128 / preview_512]
Thumb --> Attr[10 写 attribution.txt 即便 CC0]
Attr --> Upload[11 上传到 public/assets/uuid/]
Upload --> DB[12 INSERT assets row source_type=platform review_status=approved trust_score=80]
DB --> Done[完成]
```
### 4.3 导入脚本骨架(TypeScript / Node.js,从 Quaternius 批量)
```typescript
// scripts/import_quaternius.ts
//
// 用法:pnpm tsx scripts/import_quaternius.ts --pack=furniture-kit --limit=50
//
// 前置依赖(package.json):
// "@gltf-transform/core", "@gltf-transform/extensions", "@gltf-transform/functions",
// "@supabase/supabase-js", "sharp", "open-clip-ts"
// 环境变量:SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / QUATERNIUS_REPO_DIR
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import { weld, dedup, meshopt, textureCompress } from '@gltf-transform/functions';
import { createClient } from '@supabase/supabase-js';
import sharp from 'sharp';
import { readdir } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
const SEMANTIC_FROM_FILENAME: Record<string, string> = {
bed: 'bed', chair: 'chair', table: 'table', sofa: 'sofa', desk: 'table',
shelf: 'storage', dresser: 'storage', tv: 'television',
};
const STYLE_FROM_PACK: Record<string, string> = {
'furniture-kit': 'minimal', 'midcentury': 'retro', 'modern': 'nordic',
};
const supa = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
async function importOne(srcPath: string, pack: string) {
const asset_id = randomUUID().replace(/-/g, '');
const name = srcPath.split('/').pop()!.replace(/\.glb$/, '');
// 1. 加载 + 压缩
const doc = await io.read(srcPath);
await doc.transform(
weld(), dedup(),
meshopt({ level: 'medium' }),
textureCompress({ encoder: 'ktx2', targetFormat: 'auto', quality: 90 })
);
const glb = await io.writeBinary(doc);
if (glb.byteLength > 1_000_000) {
console.warn(`[skip ${name}] ${glb.byteLength}B > 1 MB`); return;
}
// 2. bbox / volume
const scene = doc.getRoot().listScenes()[0];
let min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
scene.traverse((node) => {
const mesh = node.getMesh(); if (!mesh) return;
mesh.listPrimitives().forEach(p => {
const pos = p.getAttribute('POSITION'); if (!pos) return;
for (let i = 0; i < pos.getCount(); i++) {
const v = [0, 0, 0]; pos.getElement(i, v);
for (let k = 0; k < 3; k++) {
if (v[k] < min[k]) min[k] = v[k];
if (v[k] > max[k]) max[k] = v[k];
}
}
});
});
const volume_m3 = (max[0]-min[0]) * (max[1]-min[1]) * (max[2]-min[2]);
// 3. anchor = bbox 底面中心;forward/up 默认 +Z/+YAL-1
const anchor_local = {
position: [(min[0]+max[0])/2, min[1], (min[2]+max[2])/2],
forward_axis: '+Z', up_axis: '+Y',
};
// 4. dominant_color(用首个材质的 baseColorFactor 近似;正式版用 K-Means
const mat = doc.getRoot().listMaterials()[0];
const bc = mat?.getBaseColorFactor() ?? [0.6, 0.6, 0.6, 1];
const dominant_color = '#' + bc.slice(0, 3)
.map((c: number) => Math.round(c * 255).toString(16).padStart(2, '0')).join('');
// 5. CLIP tags(伪代码;正式实现挂 open-clip-ts 对预设词典 zero-shot 分类)
const tags = await clipExtractTags(srcPath);
const matchedKey = Object.keys(SEMANTIC_FROM_FILENAME)
.find(k => name.toLowerCase().includes(k));
const semantic_class = matchedKey ? SEMANTIC_FROM_FILENAME[matchedKey] : 'storage';
const style_tag = STYLE_FROM_PACK[pack] ?? 'minimal';
// 6. 缩略图(假设外部已渲好同名 .png;正式版走 headless three.js
const thumb128 = await sharp(srcPath.replace('.glb', '.png'))
.resize(128, 128).webp({ quality: 85 }).toBuffer();
const preview512 = await sharp(srcPath.replace('.glb', '.png'))
.resize(512, 512).webp({ quality: 90 }).toBuffer();
// 7. attribution.txtCC0 也保留来源 URL
const attribution =
`Quaternius "${name}" — CC0 Public Domain\n` +
`https://github.com/quaternius/${pack}\n`;
// 8. 上传 Storage
const root_path = `assets/${asset_id}`;
await Promise.all([
supa.storage.from('public').upload(`${root_path}/model.glb`, glb,
{ contentType: 'model/gltf-binary' }),
supa.storage.from('public').upload(`${root_path}/thumb_128.webp`, thumb128,
{ contentType: 'image/webp' }),
supa.storage.from('public').upload(`${root_path}/preview_512.webp`, preview512,
{ contentType: 'image/webp' }),
supa.storage.from('public').upload(`${root_path}/attribution.txt`,
Buffer.from(attribution, 'utf-8'), { contentType: 'text/plain' }),
]);
// 9. INSERT assets 行(service_role 绕过 RLS
const { error } = await supa.from('assets').insert({
id: asset_id, kind: 'furniture', name, semantic_class,
glb_path: `public/${root_path}/model.glb`,
thumbnail_path: `public/${root_path}/thumb_128.webp`,
license: 'CC0',
source_url: `https://github.com/quaternius/${pack}`,
tags, style_tag,
anchor_local, bbox_local: { min, max },
volume_m3, dominant_color,
source_type: 'platform', review_status: 'approved', trust_score: 80,
});
if (error) throw error;
console.log(`[ok] ${name}${asset_id} (${(glb.byteLength/1024).toFixed(1)} KB)`);
}
async function clipExtractTags(p: string): Promise<string[]> {
// 伪代码:真实实现挂 open-clip-ts,对 ['wood','metal','fabric','glass',...] 做多标签分类
return ['wood', 'natural', 'mid_century'];
}
(async () => {
const args = Object.fromEntries(
process.argv.slice(2).map(s => s.replace(/^--/, '').split('='))
);
const dir = join(process.env.QUATERNIUS_REPO_DIR!, args.pack);
const files = (await readdir(dir)).filter(f => f.endsWith('.glb'));
for (const f of files.slice(0, parseInt(args.limit ?? '50', 10))) {
try { await importOne(join(dir, f), args.pack); }
catch (e) { console.error(`[fail] ${f}: ${(e as Error).message}`); }
}
})();
```
> **审计要点**:导入脚本以 `SUPABASE_SERVICE_ROLE_KEY` 运行,绕过 RLS;仅运营人员可在受控环境执行;每次执行写一行到运营内部审计表(建议增量;不展开 DDL)。
### 4.4 CC-BY 资产的差异化处理(v0.5 起开放)
| 步骤 | CC0MVP | CC-BYv0.5+ |
|------|-----------|--------------|
| `attribution.txt` | 可选 | **必须**,格式 `"<creator>" <https://url> CC-BY-4.0` |
| AssetPicker 卡片 | 不显示作者 | 卡片底部小字「by {creator}」 |
| Remix 引用时 | 无追加 | overlay 自动追加 `attribution: { asset_id, creator, license }` |
| Remix 详情页 | 不显示 | 页脚「Assets in this remix」区块列出全部 CC-BY 资产 + 作者署名 |
---
## 5. 用户上传 UGC 资产的完整流程(最核心章节)
### 5.1 上传入口与 UX
#### 5.1.1 入口路由(**建议增量** to [`04_web_app_plan.md`](04_web_app_plan.md) §1.1 路由表)
| ID | 路由 | 描述 |
|----|------|------|
| **R-18**(建议增量) | `/me/assets` | 用户主页「我的资产」Tab(列表 + 状态徽章) |
| **R-18a**(建议增量) | `/me/assets/new` | 上传向导(5 步) |
| **R-18b**(建议增量) | `/me/assets/[asset_id]/edit` | 编辑元数据(仅 `review_status='pending'` 时可改) |
| **R-19**(建议增量) | `/assets` | 公共资产浏览页(AssetPicker 的独立网页版) |
| **R-19a**(建议增量) | `/assets/[asset_id]` | 资产详情页(含使用此资产的 Remix 列表) |
| **R-17b**(建议增量) | `/admin/assets` | 审核员资产工作台(扩展自既有 R-17 `/admin/reports` |
#### 5.1.2 入口可见性
| 位置 | CTA 文案 | 触达条件 |
|------|---------|---------|
| 发现页 `/` 底部 | 「成为创作者 — 上传你的第一件资产 →」 | 登录用户且 `creator_uploaded_count=0` |
| 用户主页 `/u/[handle]` | 顶部 Tab「我的资产」 | 仅自己可见上传按钮;他人看到的是已 approved 资产列表 |
| Remix 编辑器 AssetPicker 底部 | 「找不到合适的?上传自己的 →」按钮 | 登录用户始终可见 |
| Header 用户菜单 | 「我的资产」菜单项 | 登录用户始终可见 |
#### 5.1.3 上传向导 5 步(`/me/assets/new`
```mermaid
flowchart LR
S1[Step1 选文件] --> S2[Step2 预览+确认朝向]
S2 --> S3[Step3 填元数据]
S3 --> S4[Step4 协议同意]
S4 --> S5[Step5 提交 pending]
S5 -.审核24-72h.-> Done[approved or rejected]
```
| 步骤 | UI 元素 | 校验 |
|------|---------|------|
| **1 选文件** | Dropzone + 文件选择器;接受 `.glb` / `.usdz` / `.gltf+bin+textures.zip` | 单文件 ≤ 10 MBworker 端再压到 ≤ 1 MB |
| **2 自动预览** | R3F `<Canvas>` 内嵌即时渲染 + OrbitControls;下方 `forward_axis` / `up_axis` 6 选 1 单选器 | 必须确认朝向,否则下一步按钮禁用 |
| **3 元数据** | name(≤ 80 字符)/ kind 单选(家具/材质)/ semantic_class 16 选 1(家具)或 6 选 1(材质)/ style_tag 7 选 1 / tags ≤ 8 个 | semantic_class、style_tag 必填(AL-2 / AL-3 |
| **4 协议** | 单选「我声明此资产为原创或合法授权」+「我同意按 CC0 发布(MVP 强制)」 | 两个 checkbox 必勾 |
| **5 提交** | 点击「提交审核」→ 调 E-A1 拿 presigned URL → PUT Storage → 调 E-A2 标 complete → 入审核队列 | — |
### 5.2 自动化预处理 Worker
触发自 E-A2 `POST /functions/v1/asset-upload-complete`**复用** [`02_api_contract.md`](02_api_contract.md) §3 既有 Transcode Worker 的基础设施(同 Cloud Run / Docker / 重试策略),但走独立的 `asset-` 分支函数集。
#### 5.2.1 完整 18 步序列图
```mermaid
sequenceDiagram
autonumber
participant U as 用户
participant Web as Web 上传向导
participant Edge as Edge Function
participant Stg as Supabase Storage
participant DB as Postgres
participant W as Asset Worker
participant Mod as Auto Moderator
participant Q as asset_review_queue
participant R as Reviewer
U->>Web: 完成 5 步向导
Web->>Edge: POST asset-upload-init filename size mime
Edge->>DB: INSERT assets row review_status=pending source_type=user_upload
Edge->>Stg: 签 presigned PUT URL TTL=300s
Edge-->>Web: asset_id + presigned_url
Web->>Stg: PUT raw file 到 tmp upload bin
Web->>Edge: POST asset-upload-complete asset_id metadata
Edge->>W: enqueue process_asset
W->>Stg: GET tmp upload bin
W->>W: 格式校验 拒 obj fbx 通过 glb usdz
W->>W: 安全扫描 embedded scripts malicious buffer
W->>W: 如 usdz 转 glb usdzconvert
W->>W: KTX2 Meshopt 压缩
W->>W: 三角面数检查 家具 50k 上限 超则 reject
W->>W: 自动计算 bbox volume anchor 默认底面中心
W->>W: 提取 dominant_color tags CLIP
W->>W: PBR 完整性校验 材质必含 base_color normal roughness metallic AL-8
W->>W: 生成 thumb_128 preview_512 webp
W->>Mod: NSFW NSFWJS 与 PII 人脸检测
alt 命中 NSFW>=0.9 或 含人脸
Mod-->>DB: UPDATE assets SET review_status=rejected rejection_reason
Mod->>U: 站内信通知
else 通过
W->>Stg: 上传 model glb thumb preview manifest
W->>DB: UPDATE assets 完整元数据 review_status 保持 pending
W->>Q: INSERT asset_review_queue priority=5 sla=72h
Q->>R: 推送审核任务
end
```
#### 5.2.2 步骤明细表
| ID | 步骤 | 工具 | 失败码 | 是否阻塞 |
|----|------|------|--------|---------|
| W-1 | 格式校验 | mime + 文件头 magic bytes 检查 | `ASSET_FORMAT_UNSUPPORTED` | 阻塞 |
| W-2 | 安全扫描 | 自定义 glTF 解析:检查 `extras.script` / 异常 binary chunk 长度 | `ASSET_MALICIOUS` | 阻塞 |
| W-3 | usdz→glb | `usdzconvert` | `USDZ_DECODE_FAILED` | 阻塞 |
| W-4 | 压缩 | `gltf-transform meshopt + textureCompress(ktx2)` | `MESH_COMPRESSION_FAILED` | 阻塞 |
| W-5 | 三角面数限制 | 自研:scene.traverse 累加 indices.count/3 | `ASSET_OVER_BUDGET`(家具 > 50k tri / 材质 quad > 5k tri | 阻塞 |
| W-6 | bbox / volume / anchor | 同 §4.3 import 脚本逻辑 | — | — |
| W-7 | PBR 校验(材质) | 检查 `pbr.base_color_tex` / `normal_tex` / `roughness` / `metallic` 全在 | `ASSET_PBR_INCOMPLETE`AL-8 | 阻塞 |
| W-8 | dominant_color + tags | K-Means + CLIP zero-shot | `CLIP_INFERENCE_FAILED` | 可降级(用空 tags 进人工审) |
| W-9 | 缩略图 | headless three.js → sharp resize webp | `THUMBNAIL_FAILED` | 可降级(用占位图) |
| W-10 | Auto Moderator | NSFWJS + 人脸 detection(贴图扫描) | `ASSET_NSFW_BLOCKED` / `ASSET_PII_DETECTED` | **直接 rejected** |
### 5.3 资产审核(人工 + 自动)
#### 5.3.1 自动初筛(Mod 端)
| 信号 | 阈值 | 动作 |
|------|------|------|
| NSFWNSFWJS 对 `preview_512.webp` | `score ≥ 0.9` | 直接 `review_status='rejected'` + `rejection_reason='auto_nsfw'` |
| NSFW | `0.5 ≤ score < 0.9` | 入队列 priority=2(高于普通新上传) |
| 三角面数(家具) | `> 50 000 tri` | 直接 rejected + `rejection_reason='over_triangle_budget'` |
| 三角面数(材质 quad | `> 5 000 tri` | 同上 |
| 贴图含人脸(face detection | 任一面孔 | rejected + `rejection_reason='pii_face_detected'` |
| 与既有资产 SHA256 重复 | 命中 | rejected + `rejection_reason='duplicate_of=<asset_id>'` |
| 重复上传同一文件 | 同 uploader 24h 内 ≥ 3 次失败 | 触发用户级速率限制(与 §8 信任分联动) |
#### 5.3.2 人工审核工作台(`/admin/assets`**建议增量** to [`04_web_app_plan.md`](04_web_app_plan.md) R-17
布局(沿用既有 `/admin/reports` 风格):
| 区域 | 内容 |
|------|------|
| 左侧列表 | `asset_review_queue``priority ASC, enqueued_at ASC` 排序;徽章显示 sla 剩余时间 |
| 右上预览 | R3F `<Canvas>` 360° 旋转预览;显示 `triangle_count / volume_m3 / bbox` 与压缩后大小 |
| 右下元数据 | uploader、semantic_class、style_tag、tags、license、`auto_signals` JSON 摘要 |
| 底部动作栏 | 3 按钮:Approve / Approve with edits / Reject(弹窗填 reason + Escalate(升给 owner-team |
#### 5.3.3 三种审核动作
| 动作 | 后果 | trust_score 起步 |
|------|------|-----------------|
| **Approve** | `review_status='approved'`,立即可在 AssetPicker 中被搜到 | 30(默认新上传) |
| **Approve with edits** | 审核员先补全 `anchor_local` / `tags` / `style_tag` 等字段,再走 Approve | 30 |
| **Reject** | 必填 `rejection_reason`asset 软删(`deleted_at`),文件物理保留 7 天供作者下载备份 | — |
#### 5.3.4 SLA 与超时回退
- **SLA 72 h**(与 [`10_governance.md`](10_governance.md) §3.1 MVP 1 兼职 reviewer + 30-50 工单/日上限对齐)
- **超时**:72 h 未审,资产自动以 `trust_score=20` 上架(**风险**,需明确告知 reviewer 团队)+ 工单升级到 owner-team
- 与 [`10_governance.md`](10_governance.md) §2.1 既有 NSFW/敏感词流水线**复用阈值**NSFW ≥ 0.9 硬下架,0.5-0.9 入队列
### 5.4 资产 Remix(用户基于已有资产二次创作)
#### 5.4.1 触发入口
- 资产详情页 `/assets/[asset_id]` 右上「Fork 这个资产」按钮(MVP 仅 `kind='material'` 开放;家具 Remix 延后到 v0.5
- Fork 后进入轻量编辑器:调 base_color / roughness / metallic 滑块;不允许改 mesh
#### 5.4.2 数据流
```
parent asset (CC0 or CC-BY)
|
v
POST /asset-fork { parent_asset_id, modifications: { pbr_override: {...} } }
|
v
INSERT new assets row:
source_type = 'remix_of'
parent_asset_id = <parent>
uploader_id = auth.uid()
review_status = 'pending'
license = 父协议(CC0 父可选 CC0/CC-BYCC-BY 父强制 CC-BY
pbr = 父 pbr 叠加 modifications.pbr_override
trust_score = round(parent.trust_score * 0.8)
```
#### 5.4.3 协议传染规则(与 §9 一致)
| 父 license | 子可选 license | 强制行为 |
|-----------|---------------|---------|
| CC0 | CC0 或 CC-BYv0.5+ | 子选 CC-BY 时仅署"作为 remix 作者的自己" |
| CC-BY-4.0 | **必须** CC-BY-4.0 | 子的 `attribution.txt` 必须**叠加**父的原作者署名(追加,不替换) |
---
## 6. API 端点增量(AL-10 落地)
**建议增量** to [`02_api_contract.md`](02_api_contract.md) §2.1 端点全表,新增 8 条 E-A1~E-A8,沿用既有命名风格与速率限流维度。
### 6.1 端点全表
| ID | METHOD | 路径 | 类型 | 入参 | 出参 | 鉴权 | 速率 | 业务错误码 |
|----|--------|------|------|------|------|------|------|-----------|
| **E-A1** | POST | `/functions/v1/asset-upload-init` | Edge | `{ filename, size, mime, kind }` | `{ asset_id, presigned_put_url, expires_at }` | user | **10/h/user** | `FILE_TOO_LARGE`, `ASSET_FORMAT_UNSUPPORTED`, `QUOTA_EXCEEDED`, `CREATOR_FROZEN` |
| **E-A2** | POST | `/functions/v1/asset-upload-complete` | Edge | `{ asset_id, name, semantic_class, style_tag, tags[], license, kind, anchor_local? }` | `{ asset_id, review_status: 'pending', queue_position }` | user(owner) | 10/h/user | `ASSET_NOT_FOUND`, `ASSET_SOURCE_MISSING`, `ASSET_METADATA_INVALID` |
| **E-A3** | GET | `/rest/v1/assets?...`(详见 §6.2 | PostgREST | query string | `Asset[]` | anon | 60/min/IP | `RLS_DENIED`, `SEARCH_QUERY_TOO_SHORT` |
| **E-A4** | POST | `/functions/v1/asset-review-action` | Edge | `{ asset_id, action, reason?, edits? }` | `{ ok, new_review_status }` | reviewer | 100/h/reviewer | `ASSET_NOT_FOUND`, `ASSET_ALREADY_REVIEWED`, `REVIEWER_FORBIDDEN` |
| **E-A5** | POST | `/functions/v1/asset-fork` | Edge | `{ parent_asset_id, name, pbr_override?, license }` | `{ new_asset_id, review_status: 'pending' }` | user | **5/h/user** | `ASSET_NOT_FOUND`, `ASSET_FORK_LICENSE_CONFLICT`, `ASSET_FORK_NON_REMIXABLE` |
| **E-A6** | POST | `/functions/v1/asset-report` | Edge | `{ asset_id, reason, detail? }` | `{ report_id }` | user | 5/h/user | `ASSET_NOT_FOUND`, `REPORT_DUPLICATE` |
| **E-A7** | POST | `/rest/v1/asset_collections` | PostgREST | `{ name, asset_ids[], is_public, description? }` | `AssetCollection` | user | 20/h/user | `RLS_DENIED`, `COLLECTION_NAME_TAKEN` |
| **E-A8** | DELETE | `/rest/v1/assets?id=eq.{asset_id}` | PostgREST | path | `204` | owner(pending) | 5/h/user | `RLS_DENIED`, `ASSET_ALREADY_APPROVED` |
> 注:E-A8 仅 `review_status='pending'` 时允许 owner 走 PostgREST DELETE(由 §2.5 policy `assets_delete_owner_pending` 强制);approved 后须改走 E-A4 reviewer 路径(reviewer 可执行 `withdrawn` 动作,软删并清 Storage 文件)。
### 6.2 E-A3 资产检索查询参数语义(核心,AL-10 落地)
```
GET /rest/v1/assets
?review_status=eq.approved # 默认仅展示 approved(前端固定)
&kind=eq.furniture # 'furniture' | 'material'
&semantic_class=eq.sofa # 16 类 / 6 类
&style_tag=eq.nordic # 7 类
&bbox_filter=lo,hi # 体量范围 m^3
&color=hue:200,tol:20 # 主色相 hue ± tolHSL 空间)
&license=in.(CC0,CC-BY-4.0) # 协议白名单
&q=oak # name + tags 模糊(pg_trgm + GIN
&source_type=in.(platform,user_upload)
&order=trust_score.desc,use_count.desc
&limit=20
&offset=40
```
#### 6.2.1 `bbox_filter=lo,hi` 语义
- 用户在 AssetPicker 选中一个家具,其原 OBB 体量 `vol_target` m³;前端拼 `lo = vol_target * 0.5`, `hi = vol_target * 1.5`
- 实现:在 RPC `search_assets` 中翻译为 `WHERE volume_m3 BETWEEN $lo AND $hi`
- 容差:默认 ±50%(含 0.5×~1.5×;过严会让候选集小于 5 件,过松失去过滤意义);与 [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §4.2 推荐的 ±20% 兼容(参数化由前端控制)
- 索引:`assets_volume_btree`(§2.4
#### 6.2.2 `color=hue:200,tol:20` 语义
- 在 HSL 色空间筛主色相 ± tol(角度 0-360)
- 实现:服务端把 `dominant_color` 的 hex → HSL,做 `WHERE ABS(hue_diff(...)) < $tol`
- 简化版(MVP):先用六色桶(warm/cold/neutral/red/blue/green)做粗筛,避免每次 SQL 调用 HSL 转换函数
- 进阶(P2):LAB 距离 `< 阈值`,更感知一致
#### 6.2.3 复合查询 SQL 实现示例
```sql
-- 建议增量:暴露 RPC,便于复杂参数封装
CREATE OR REPLACE FUNCTION public.search_assets(
p_kind text DEFAULT NULL,
p_semantic_class text DEFAULT NULL,
p_style_tag text DEFAULT NULL,
p_vol_lo numeric DEFAULT NULL,
p_vol_hi numeric DEFAULT NULL,
p_hue smallint DEFAULT NULL,
p_hue_tol smallint DEFAULT 20,
p_q text DEFAULT NULL,
p_limit int DEFAULT 20,
p_offset int DEFAULT 0
) RETURNS SETOF public.assets AS $$
SELECT * FROM public.assets a
WHERE a.review_status = 'approved'
AND a.deleted_at IS NULL
AND (p_kind IS NULL OR a.kind = p_kind)
AND (p_semantic_class IS NULL OR a.semantic_class = p_semantic_class)
AND (p_style_tag IS NULL OR a.style_tag = p_style_tag)
AND (p_vol_lo IS NULL OR a.volume_m3 BETWEEN p_vol_lo AND p_vol_hi)
AND (p_hue IS NULL OR hue_diff_360(hue_of_hex(a.dominant_color), p_hue) <= p_hue_tol)
AND (p_q IS NULL OR a.name ILIKE '%'||p_q||'%' OR a.tags && string_to_array(p_q, ','))
ORDER BY a.trust_score DESC, a.use_count DESC
LIMIT p_limit OFFSET p_offset;
$$ LANGUAGE sql STABLE;
```
- **性能目标**:100k 行规模下,`semantic_class + bbox_filter + style_tag` 三维过滤 + 排序 ≤ 200 ms(依赖 §2.4 复合索引)
- **CDN 缓存**60 s(按 query string 哈希);登录态附 `Cache-Control: private`
### 6.3 错误码增量
**建议增量** to [`02_api_contract.md`](02_api_contract.md) §7
| 错误码 | HTTP | 含义 | 出现端点 |
|--------|------|------|----------|
| `ASSET_FORMAT_UNSUPPORTED` | 415 | 不接受 `.obj` / `.fbx` | E-A1, W-1 |
| `ASSET_OVER_BUDGET` | 413 | 三角面数或文件大小超限 | W-5 |
| `ASSET_PBR_INCOMPLETE` | 400 | 材质 PBR 字段缺失(AL-8 | W-7 |
| `ASSET_NSFW_BLOCKED` | 403 | NSFW score ≥ 0.9 自动下架 | Mod |
| `ASSET_PII_DETECTED` | 403 | 贴图含人脸/证件 | Mod |
| `ASSET_MALICIOUS` | 400 | 文件结构异常或含 embedded 脚本 | W-2 |
| `ASSET_ALREADY_REVIEWED` | 409 | 同一 asset 不允许重复审 | E-A4 |
| `ASSET_FORK_LICENSE_CONFLICT` | 400 | 父 CC-BY 子选 CC0 不允许 | E-A5 |
| `ASSET_FORK_NON_REMIXABLE` | 403 | 父资产 `kind='furniture'` 在 MVP 不允许 fork | E-A5 |
| `CREATOR_FROZEN` | 403 | `creator_trust_score < 0` 上传被冻结 | E-A1 |
| `REVIEWER_FORBIDDEN` | 403 | 无 reviewer role | E-A4 |
| `COLLECTION_NAME_TAKEN` | 409 | 同 owner 收藏夹重名 | E-A7 |
| `ASSET_ALREADY_APPROVED` | 409 | approved 资产不可走 owner DELETE | E-A8 |
---
## 7. AssetPicker 排序与发现策略
### 7.1 默认排序综合公式
AssetPicker 默认查询 `order=trust_score.desc, use_count.desc`,但实际后端按下列加权重排(在 RPC `search_assets` 顶层包一层):
```
score = 0.4 * normalize(trust_score, 0-100)
+ 0.3 * normalize(log1p(use_count), log scale clip 0-1)
+ 0.2 * license_bonus -- CC0=1.0, CC-BY=0.5
+ 0.1 * recency_decay(created_at) -- 7 天内 1.0, 30 天 0.5, 90 天 0.2
- 1.0 * is_reported -- 当前有未结举报扣分(强烈降权)
```
### 7.2 冷启动策略
| 阶段 | 规则 |
|------|------|
| 上传后 0-7 天 | `trust_score` 固定 30,禁止变化;优先暴露给 Remix 编辑器底部「新上传推荐」窗口(占 10% 流量配额) |
| 第 8 天 | 根据 `download_count / use_count / report_count` 进入 §8 信任分动态算法 |
| 资产首次被 use 时 | 立即将 `trust_score += 2`(实时反馈),但单 7 天窗口内增量上限 ≤ 10 |
### 7.3 三类资产权重差异
| `source_type` | 默认 `trust_score` | 排序额外因子 |
|---------------|-------------------|-------------|
| `platform` | 80 | × 1.0 |
| `user_upload` | 30 → 动态 | × 0.85(同分数时让位 platform |
| `remix_of` | round(parent × 0.8) | × 0.7(避免刷量) |
### 7.4 防作弊
- 同 IP 同 asset 24h 内 use_count 增量上限 = 3trigger 端去重)
- 同 uploader 自我点赞收藏不计 use_count
- `trust_score` 24h 内变化幅度 ≤ ±15(防恶意刷分/被刷)
---
## 8. 创作者声誉与信任分(AL 衍生需求)
### 8.1 双重信任分模型
| 维度 | 字段 | 范围 | 含义 |
|------|------|------|------|
| **资产级** | `assets.trust_score` | 0-100 | 单件资产的可信度,影响 §7 排序 |
| **创作者级** | `users.creator_trust_score` | -100 ~ 100 | 创作者整体声誉,影响上传权限与跳审 |
### 8.2 `creator_trust_score` 计分规则
| 事件 | 变化 | 触发位置 |
|------|------|---------|
| 初始注册 | = 50 | `users` 默认值 |
| 资产 approve | +5 | E-A4 action=approve |
| 资产 reject | -10 | E-A4 action=reject |
| 资产被举报且 reviewer 判定违规 | -20 | `asset_reports.resolution='confirmed_violation'` |
| 资产被 use_count 累计 ≥ 100 | +10 | trigger(每件资产仅奖励一次) |
| 资产被收藏(加入 `asset_collections`)累计 ≥ 100 次 | +5 | trigger |
| 主动撤回 pending 资产 | 0 | E-A8 |
| 申诉胜诉(rejected → approved | +15 | reviewer 修正 |
### 8.3 阈值规则
| 区间 | 状态 | 上传体验 |
|------|------|---------|
| `< 0` | **冻结** | E-A1 直接 `CREATOR_FROZEN`;不再接受上传 |
| `0 ~ 29` | 低信任 | 上传**必须**走人工预审(自动初筛不能放行;优先级降至 priority=7 |
| `30 ~ 79` | 标准 | 默认流程 |
| `80 ~ 100` | **Trusted Creator** | 上传可**跳过**人工审核(仅自动审,approved 后直接上架);`creator_badge='trusted'` |
| `≥ 95``creator_uploaded_count ≥ 50` | **Pro Creator** | 同上 + 优先展示在「创作者推荐」位 |
### 8.4 与 v0.2 既有信任分系统的对齐
本节是 [`10_governance.md`](10_governance.md) §3.2 P1 信任分系统的**具体落地**:
| v0.2 §3.2 P1 规则 | 本节对应 |
|-------------------|---------|
| 初始信任分 50 | `users.creator_trust_score DEFAULT 50` |
| 每次成功举报 +5 | §8.2「资产被举报判违规 -20」是被举报方;举报方 +5 走 [`10_governance.md`](10_governance.md) 现有 user-level 信任分(独立维度),不在本表 |
| 满 100 分可申请志愿者审核团 | `creator_trust_score = 100 AND creator_uploaded_count >= 50` → 弹申请入口;具体审核团 SOP 走 v0.5 |
---
## 9. 版权与协议管理(AL-7 落地)
### 9.1 MVP 与 v0.5 的两阶段策略
| 阶段 | 用户可选 license | UX |
|------|----------------|-----|
| **MVPv0.2~v0.4** | 仅 CC0 | 上传向导 §5.1.3 Step 4 默认且禁用切换;显示「MVP 期所有资产强制 CC0 — 详见 [社区准则 §4](../CrowdRoom/10_governance.md#4-web-移交契约p-w-3--p-w-6-逐条决策)」 |
| **v0.5+DAU > 5 000** | CC0 或 CC-BY-4.0 | 上传向导 Step 4 加单选;选 CC-BY 必填「原作者名 + 原始 URL」(即便是自己原创,URL 可填自己主页) |
### 9.2 强约束(与 [`10_governance.md`](10_governance.md) §4 P-W-4 一致)
-**接受**CC0、CC-BY-4.0v0.5+
-**永不接受**
- **CC-BY-SA**(病毒条款,污染 Remix 树)
- **CC-BY-NC**(与 CrowdRoom 平台 BY-NC 双重叠加,堵死 Remix 商业化)
- **任何商业素材**(涉及税务、对账、退款,超 MVP 预算)
### 9.3 attribution 自动透传链路
```mermaid
flowchart LR
A[CC-BY 资产 A] --> Remix[Remix 引用 A]
Remix --> Overlay[remix_overlay.json 自动追加 attribution: { asset_id, creator, license }]
Overlay --> RemixPage[Remix 详情页页脚 Assets in this remix]
RemixPage --> Display[显示 by creator CC-BY 4.0]
```
### 9.4 侵权举报与下架流程
```
用户举报(E-A6
|
v
asset_reports INSERT, 累计独立举报数 >= 3
|
v
trigger: assets.review_status = 'pending' (自动隐藏)
+ asset_review_queue INSERT priority=1
+ Edge Function 通知 uploader 站内信
|
v
Reviewer 24h 内决策
|--- false_report → review_status='approved' 恢复
|--- confirmed_violation → review_status='withdrawn'
+ Storage 文件物理删除
+ uploader.creator_trust_score -= 20
+ 触发回退:所有 remix_overlay 引用此 asset_id 的 op
自动 fallback 到「占位 box」(asset_id 仍可在 overlay 中保留以备申诉)
```
### 9.5 「占位 box」fallback 规则
- Web 端加载 overlay 时,对每个 `op.asset_id` 调 E-A3 校验存在;
- 若 asset 已 `withdrawn``deleted`,渲染端用一个灰色 OBB 占位 mesh 替代(尺寸取自原 `target_item.obb`),上方浮一行 `"Asset withdrawn"`
- 与 [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §9 `applyOverlay` 的「asset 加载失败兜底」分支兼容
---
## 10. 资产收藏 / 集合 / 个人主页(社区飞轮)
### 10.1 用户收藏夹(`asset_collections``is_official=false`
- 用户可建多个收藏夹(如 "我的客厅灵感"、"侘寂风备选"
- 每个收藏夹 `asset_ids[]` 上限 200 个;超出走「分页拆分」CTA
- 收藏夹可 `is_public=true` 公开,被发现页「灵感板」抓取展示
- 在 AssetPicker 顶栏可一键「+ 加入收藏夹」(弹出收藏夹选择 + 新建)
### 10.2 官方风格集(`is_official=true`
- 仅 reviewer / owner-team 可创建(service_role 写)
- 典型示例:
- "侘寂风家具 30 件套"30 件 `style_tag='wabi_sabi'` 的精选)
- "Quaternius Spring 2026 新品"
- "亚洲设计师精选"v0.5+ 创作者 spotlight
- 发现页 `/` 顶部「编辑精选」位轮播展示
### 10.3 创作者个人主页扩展
扩展 [`04_web_app_plan.md`](04_web_app_plan.md) §1.1 R-4 `/u/[handle]` 既有页:
| 新增模块 | 内容 |
|---------|------|
| 「我的资产」Tab | 网格列出该用户 `approved` 资产;右上 stats:上传数 / 总使用数 / 总下载数 |
| Trusted Creator 徽章 | 头像右下角小盾牌(`creator_badge='trusted'` |
| Pro Creator 徽章 | 头像右下角金色齿轮(`creator_badge='pro'` |
| 「联系/赞赏」按钮 | v0.5+MVP 隐藏 |
| 收藏夹列表(仅自己可见私有) | 公开收藏夹列出 |
---
## 11. 移动端可访问性(iOS App 是否能上传资产?)
### 11.1 决策:MVP iOS App **不支持**上传资产到资产库
| 维度 | 理由 |
|------|------|
| **用户画像** | iOS 端用户的核心动作是「扫描房间」(RoomPlan),不是「建模家具」 |
| **工具链** | 自制 .glb 的工具链都在 PCBlender / 3ds Max / Rhino),手机上无创作场景 |
| **审核成本** | 手机端上传更容易出现"随手拍照伪装 3D"的低质内容,自动初筛拦不住 |
| **开发成本** | iOS 端走 §5 完整上传向导需重写 SwiftUIMVP 期 ROI 低 |
### 11.2 v0.5 起的轻量入口(候选)
- iPhone 拍照 → Object CaptureiOS 17+)→ 自动生成 .glb → 走简化版 §5 流程
- 仅支持「桌面摆件」级小物体(< 30 cm 边长)
- 自动 `style_tag='unknown'`,强制进入 priority=2 人工审
### 11.3 iOS App 仍可以做的资产相关动作
| 动作 | 是否支持 |
|------|---------|
| 浏览资产库(只读) | ✅(MVP 即支持,复用 Web 的 E-A3 |
| 收藏资产到 `asset_collections` | ✅ |
| 举报资产(E-A6 | ✅ |
| 上传资产 | ❌(MVP)→ ✅(v0.5 Object Capture 路径) |
| Fork / Remix 资产 | ❌(MVP)→ ✅(v0.5 |
---
## 12. 性能与存储成本
### 12.1 存储成本估算
| 资产数 | 平均单件 | 总存储 | Cloudflare R2 月成本 | Supabase Storage 月成本 |
|--------|---------|--------|--------------------|----------------------|
| 1 万 | 600 KBmodel+thumb+preview | ~6 GB | ~$0.10 | 免费档内 |
| 10 万 | 600 KB | ~60 GB | ~$1.50 | $1.25(超 1 GB 后 $0.021/GB |
| 100 万 | 600 KB | ~600 GB | ~$15 | $12.6 → 此规模迁 R2 |
> 与 [`04_web_app_plan.md`](04_web_app_plan.md) §10 / [`10_governance.md`](10_governance.md) §4 P-W-6「Cloudflare 免费档 + R2 出口费」一致;MVP 期资产库不会成为成本瓶颈。
### 12.2 AssetPicker 加载性能
| 场景 | 目标 | 实现 |
|------|------|------|
| 第一屏 20 个卡片 | TTI < 1 s | 20 × 128×128 webp ≈ 200 KBCDN gzip 后),4G 网络 < 800 ms |
| 滚动加载下一屏 | < 300 ms | E-A3 `limit=20` + offset 分页,CDN 60s 缓存命中率 > 80% |
| 详情页 360° 预览 | TTI < 2 s | 预加载 model.glb(≤ 1 MBCDN+ 异步加载 preview_3d.glb |
### 12.3 检索性能
| 规模 | 三维过滤(semantic+style+bbox| 索引 |
|------|------------------------------|------|
| 10k 行 | ~30 ms | btree |
| 100k 行 | ~120 ms | btree + GIN |
| 1M 行 | ~400 ms**警戒** | 需要切 ElasticSearch / MeilisearchP2,纳入 ROADMAP |
> **触发条件**:总 `approved` 资产数 > 100k → 启动 outbox 同步到 Meilisearch;保留 Postgres 为权威源(与 [`01_data_schema.md`](01_data_schema.md) §1 D5 全文搜索演进策略一致)。
### 12.4 Worker 处理能力
| 资源 | 单次处理时间 | MVP 并发 |
|------|------------|---------|
| 用户上传 .glb5 MB → 1 MB | ~30 s(含 KTX2 + CLIP + 缩略图) | 5 并发,60 件/小时 |
| 平台 import 批量 | ~10 s/件 | 串行,360 件/小时(夜间跑) |
MVP 容量:60 件/小时 × 24 h = 1 440 件/日上限,远高于预期 50 件/日新增。
---
## 13. 与 v0.2 设计的关系矩阵
> 一张表,每行:本文档章节 / 关联的 v0.2 文档章节 / 关系类型。Reviewer 可一眼看清哪些是新增、哪些是已有。
| 本文档章节 | 关联 v0.2 章节 | 关系类型 |
|-----------|--------------|---------|
| §1 三类资产定位 | [`10_governance.md`](10_governance.md) §4 P-W-4 | **深化补充**(从"全 CC0 公共池"扩到三源流) |
| §2 `assets` 表 13 列增量 | [`01_data_schema.md`](01_data_schema.md) §3.9 | **建议增量**ALTER 不改既有列) |
| §2.7.1 `asset_review_queue` 新表 | [`10_governance.md`](10_governance.md) §2.1 自动审核流 | **新增能力**(资产分支队列) |
| §2.7.2 `asset_reports` 新表 | [`02_api_contract.md`](02_api_contract.md) §2 E-14 通用 report 端点 | **深化补充**(资产专属举报 schema |
| §2.7.3 `asset_collections` 新表 | — | **新增能力**v0.2 无收藏概念) |
| §2.7.4 `creator_trust_score` 字段 | [`10_governance.md`](10_governance.md) §3.2 P1 信任分 | **深化补充**(具体落地) |
| §3 Storage 目录 `public/assets/{id}/` | [`01_data_schema.md`](01_data_schema.md) §4 | **建议增量**(顶层 prefix 扩展) |
| §3.3 `glb_path_immutable` trigger | [`01_data_schema.md`](01_data_schema.md) §3.9 | **建议增量**AL-9 兜底) |
| §4 平台导入管线 | [`10_governance.md`](10_governance.md) §4 P-W-4 步骤 1-3 | **深化补充**(提供具体脚本骨架) |
| §5 用户上传向导 | [`04_web_app_plan.md`](04_web_app_plan.md) §1.1 路由表 | **建议增量**R-18~R-19a 路由) |
| §5.2 Asset Worker | [`02_api_contract.md`](02_api_contract.md) §3 Transcode Worker | **深化补充**(复用基础设施,新增分支) |
| §5.3 审核工作台 | [`04_web_app_plan.md`](04_web_app_plan.md) R-17 `/admin/reports` | **建议增量**R-17b `/admin/assets` |
| §6 8 个新端点 E-A1~E-A8 | [`02_api_contract.md`](02_api_contract.md) §2.1 端点表 | **建议增量** |
| §6.3 错误码增量 | [`02_api_contract.md`](02_api_contract.md) §7 | **建议增量** |
| §7 AssetPicker 排序 | [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §4 | **深化补充** |
| §8 创作者信任分 | [`10_governance.md`](10_governance.md) §3.2 | **深化补充**(具体公式) |
| §9 协议管理 | [`10_governance.md`](10_governance.md) §4 P-W-4、§8 | **直接引用 + 深化** |
| §9.5 「占位 box」fallback | [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §9 / §12 F-5 | **直接引用** |
| §10 收藏 / 集合 | [`04_web_app_plan.md`](04_web_app_plan.md) §1.1 R-4 用户主页 | **建议增量** |
| §11 iOS 边界 | [`03_ios_app_plan.md`](03_ios_app_plan.md) §1 路由 | **新增决策**(明确不做) |
| §12 性能 / 成本 | [`04_web_app_plan.md`](04_web_app_plan.md) §10、[`10_governance.md`](10_governance.md) §4 P-W-6 | **深化补充** |
---
## 14. M1 验收清单
### 14.1 12 条 checkbox 验收(M1 = MVP 8 周窗口内必达)
- [ ] **M1-01**`assets` 表通过 §2 增量 ALTER 后能存储 1 万条平台资产(数据迁移脚本无报错)
- [ ] **M1-02**:从 Quaternius 批量导入 100 件家具脚本(§4.3)能跑通,平均单件 < 15 s
- [ ] **M1-03**:用户能从 `/me/assets/new` 上传 1 个 .glb 文件(5 步向导 UX 完整)
- [ ] **M1-04**:上传完成后 5 分钟内进入审核队列(`asset_review_queue` 有对应行 priority=5
- [ ] **M1-05**:自动初筛能拒 NSFWscore ≥ 0.9/ 拒大于 50k 三角面数 / 拒含人脸贴图
- [ ] **M1-06**:审核员在 `/admin/assets` 工作台 approve 后,资产能在 AssetPicker 中通过 E-A3 被搜到
- [ ] **M1-07**AssetPicker 按 `semantic_class + bbox_filter + style_tag` 复合查询响应 < 200 ms100k 行规模)
- [ ] **M1-08**[`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) §15 的 **AL-1 ~ AL-10 全部 10 条契约**逐条达成(详见 §14.2 对照表)
- [ ] **M1-09**:资产被 use_count 累计 ≥ 100 后 `assets.trust_score` 自动 +10trigger 实测)
- [ ] **M1-10**:用户能从 `/me/assets` 创建收藏夹并放入 ≥ 5 个资产,公开后他人可见
- [ ] **M1-11**CC-BY 资产(v0.5+ 启用)在 Remix 详情页底部正确显示「Asset by {creator} (CC-BY 4.0)」
- [ ] **M1-12**:侵权举报触发后 24 h 内(confirmed_violation 路径)能从 AssetPicker 下架;占位 box fallback 生效
### 14.2 AL-1 ~ AL-10 契约最终对照表(双重显式,与 §2.6 互为冗余)
| 契约 | 验收条件 | 测试方法 | M1 状态 |
|------|---------|---------|---------|
| **AL-1** | 所有 `kind='furniture'` 资产 `anchor_local` + `bbox_local` 非空率 100% | `SELECT count(*) FROM assets WHERE kind='furniture' AND (anchor_local='{}' OR bbox_local='{}')` = 0 | [ ] |
| **AL-2** | `semantic_class` 非空率 ≥ 99%[`01_data_schema.md`](01_data_schema.md) §3.9 既有字段) | 同上 SQL 校验 | [ ] |
| **AL-3** | `style_tag` 非空率 ≥ 95%7 类 enum 覆盖 | `SELECT style_tag, count(*) FROM assets GROUP BY 1` | [ ] |
| **AL-4** | `volume_m3 > 0` 非空率 100%`dominant_color` 非空率 ≥ 95%GIN tags 索引存在 | `\d+ public.assets` 看索引 + 数据校验 | [ ] |
| **AL-5** | 100% 平台资产 `model.glb` ≤ 1 MB(家具)/ ≤ 200 KB(材质) | Storage 对象大小巡检脚本 | [ ] |
| **AL-6** | 100% 资产有 `thumb_128.webp`128×128 | Storage 路径存在性巡检 | [ ] |
| **AL-7** | `license CHECK IN ('CC0','CC-BY-4.0')` 强制;100% 资产协议 = CC0MVP 阶段) | `SELECT DISTINCT license FROM assets` | [ ] |
| **AL-8** | `kind='material'` 资产 100% 含 `pbr.base_color_tex / normal_tex / roughness / metallic` | JSONB key existence SQL | [ ] |
| **AL-9** | `assets_glb_path_lock` trigger 启用;任何 UPDATE 改 `glb_path` 都抛错 | 故意 UPDATE 测试 | [ ] |
| **AL-10** | E-A3 接受 `bbox_filter / style / semantic / color` 复合参数;响应 ≤ 200 ms | k6 压测 100k 行规模 | [ ] |
> **结论**14.2 对照表 + §2.6 字段映射表共同实现 AL-1 ~ AL-10 的**双重显式**对账。
---
## 15. 风险与开放问题
| # | 风险 | 影响 | 缓解策略 |
|---|------|------|---------|
| **R-1** | UGC 资产质量参差 → AssetPicker 体验下降 | 用户找不到好资产,Remix 转化率下降 | §7 排序公式让 platform 资产默认排前;冷启动 7 天压制 user_upload;§8 信任分淘汰低质创作者 |
| **R-2** | 版权审核难度大(用户「原创」声明无法机器验证) | 法律风险,版权方追责 | MVP 强制 CC0(用户放弃所有权);侵权举报 24 h 内下架 + Storage 删除 + 占位 box fallback;保留申诉通道 |
| **R-3** | 资产规模 > 100k 后检索性能退化 | E-A3 响应 > 200 msAssetPicker 卡顿 | §12.3 触发条件:> 100k 行 → outbox 同步到 MeilisearchPostgres 保留为权威源(P2,纳入 ROADMAP) |
| **R-4** | 平台资产爬取的法律边界(Sketchfab CC0 标错率高) | 误把非 CC0 当 CC0,被原作者追责 | §4.1 Sketchfab 子集**每件单独人工 verify**;保留 source_url + attribution.txt 即便 CC0 |
| **R-5** | Remix 衍生的协议传染(CC-BY 链可能很长) | 长链署名累积,UI 难以全部展示 | §9.3 attribution 透传链路 trigger 自动汇总;详情页页脚折叠展示「View all attributions」 |
| **R-6** | iOS 不能上传资产是否伤害创作者生态 | 移动端创作者流失 | §11.2 v0.5 引入 Object Capture 轻量入口;MVP 阶段通过「Web 上传 + 邮件分享给 iOS 用户」过渡 |
---
**章节版本**:v0.2 · 草案(子任务 10 产出,2026-05-19
**关键收获**
- v0.2 既有 `assets` 表 13 列字段增量 + 3 张支撑表 = 完整的「平台 + UGC + Remix」三源流资产库
- 8 个新端点 E-A1~E-A8 + 1 个 RPC `search_assets` = AssetPicker 复合检索 + UGC 全生命周期
- 10 条 AL 契约 100% 落地(§2.6 字段映射 + §14.2 验收对照 双重显式)
- 与 [`10_governance.md`](10_governance.md) §4 P-W-4 决策严格一致(MVP 全 CC0v0.5 起 CC-BY
**对外契约**
| 契约 | 给谁 | 一句话 |
|------|------|--------|
| **`assets` 表 13 列 ALTER + 3 张支撑表** | DBA / 后端 | 在 v0.2 §3.9 既有 schema 上做幂等 ALTER;不破坏既有迁移 |
| **8 个新端点 E-A1~E-A8** | iOS / Web / 运营 | 全部走 Edge Function 或 PostgREST,沿用 v0.2 鉴权/限流维度 |
| **AL-1 ~ AL-10 全部 10 条契约达成** | [`05_object_replacement_handbook.md`](05_object_replacement_handbook.md) 物品替换闭环 | 资产库提供的元数据足以让 AssetPicker 在 200 ms 内做 OBB 对齐替换 |
| **MVP CC0 强制 / v0.5 CC-BY 二选一** | 法务 / 运营 | 与 [`10_governance.md`](10_governance.md) §4 P-W-4 决策一致 |
| **iOS App 仅消费不生产** | iOS 团队 | MVP 不接 UGC 上传;v0.5 起评估 Object Capture 轻量入口 |