# 典藏/活动藏品体系 + 星册重构设计 > 日期:2026-04-13 > 状态:已评审通过 > 负责人:zheng020 --- ## 零、重构后页面效果 ### 星册主页(StarbookContent.vue) ``` ┌─────────────────────────────────────────────┐ │ ✕ 返回 星册 │ ← Header ├─────────────────────────────────────────────┤ │ 背景图(starbook.jpg,满屏) │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ │ │ │ │ [ 普通 ] [ 典藏 ] [ 活动 ] │ │ ← 类型Tab(横向滚动) │ │ │ │ │ ├─────────────────────────────────────┤ │ │ │ │ │ │ │ 普通 · 等级五 │ │ ← 普通:grade 大在上 │ │ ┌────────┐ ┌────────┐ ┌────────┐│ │ │ │ │ 封面 │ │ 封面 │ │ 封面 ││ │ │ │ └────────┘ └────────┘ └────────┘│ │ │ │ 铸爱-A 铸爱-B 铸爱-C ★820 │ │ │ │ │ │ │ │ ┌────────┐ ┌────────┐ ┌────────┐│ │ │ │ │ 封面 │ │ 封面 │ │ 更多> ││ │ │ │ └────────┘ └────────┘ └────────┘│ │ │ │ 铸爱-D 铸爱-E [点击更多] │ │ ← 超出6个显示"更多>" │ │ │ │ │ │ ───────────────────────────────── │ │ │ │ │ │ │ │ 普通 · 等级四 │ │ │ │ ┌────────┐ ┌────────┐ │ │ │ │ │ 封面 │ │ 封面 │ │ │ │ │ └────────┘ └────────┘ │ │ │ │ 铸爱-F 铸爱-G │ │ │ │ │ │ │ │ ...(等级三→等级一) │ │ │ │ │ │ │ └─────────────────────────────────────┘ │ │ │ ├─────────────────────────────────────────────┤ │ [广场] [星册] [铸爱] [星际] [好友] │ ← BottomNav └─────────────────────────────────────────────┘ ``` **切换到"典藏"时:** ``` │ [ 普通 ] [ 典藏 ] [ 活动 ] │ ← 典藏Tab高亮 ├─────────────────────────────────────────────┤ │ 典藏 · 限定典藏 │ ← 按category分组,无grade │ ┌────────┐ ┌────────┐ ┌────────┐ │ │ │ 封面 │ │ 封面 │ │ 更多> │ │ │ └────────┘ └────────┘ └────────┘ │ │ 典藏-A 典藏-B [点击更多] │ │ │ │ 典藏 · 经典典藏 │ │ ┌────────┐ ┌────────┐ │ │ │ 封面 │ │ 封面 │ │ │ └────────┘ └────────┘ │ │ 典藏-C 典藏-D │ │ │ │ ... │ ``` **切换到"活动"时:** ``` │ [ 普通 ] [ 典藏 ] [ 活动 ] │ ← 活动Tab高亮 ├─────────────────────────────────────────────┤ │ 活动 · 生日会 │ ← 按activity_type分组 │ ┌────────┐ ┌────────┐ │ │ │ 封面 │ │ 封面 │ │ │ └────────┘ └────────┘ │ │ 活动-A 活动-B │ │ │ │ 活动 · 演唱会 │ │ ┌────────┐ ┌────────┐ │ │ │ ... │ │ ... │ │ │ └────────┘ └────────┘ │ ``` **页面说明:** - **无槽位展示区**:新设计完全移除槽位概念,改为纯分组浏览 - **类型Tab**:`[普通] [典藏] [活动]` 三选一,点击切换 - **默认选中"普通"**:进入页面默认展示普通藏品 - **普通**:按 grade 从等级五(顶部)到等级一(底部)排列,grade 大的在上 - **典藏**:按 category 子分类分组,无 grade - **活动**:按 activity_type 分组,无 grade - **每组最多显示6个NftCard**,超出6个显示"更多>"卡片("更多>"不计入6个之内) - **"更多>"卡片**:点击跳转 `/pages/starbook/items?type=regular&category=castlove&grade=5` --- ### 查看更多页面(/pages/starbook/items) ``` ┌─────────────────────────────────────────────┐ │ ← 返回 普通 · 等级五 │ ← Header ├─────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐│ │ │ 封面图 │ │ 封面图 │ │ 封面图 ││ │ └──────────┘ └──────────┘ └──────────┘│ │ 铸爱藏品-A 铸爱藏品-B 铸爱藏品-C │ │ ★820 ★450 ★120 │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐│ │ │ 封面图 │ │ 封面图 │ │ 封面图 ││ │ └──────────┘ └──────────┘ └──────────┘│ │ 铸爱藏品-D 铸爱藏品-E 铸爱藏品-F │ │ ★98 ★45 ★30 │ │ │ │ ─────── 第 1/3 页 ─────── │ │ │ ├─────────────────────────────────────────────┤ │ [广场] [星册] [铸爱] [星际] [好友] │ └─────────────────────────────────────────────┘ ``` **页面说明:** - **路由参数**:`?type=regular&category=castlove&grade=5&page=1` - **布局**:3列网格,每行3个 NftCard - **分页**:每次加载20条,触底自动加载下一页 --- ## 一、背景与目标 ### 1.1 现状问题 - `StarbookContent.vue` 混用通用 `getMyAssetsApi`,无法区分藏品类型(普通/典藏/活动) - `assets` 表只有一套,没有类型区分字段 - 典藏藏品有子分类(category)需求,但目前表结构不支持 - 普通藏品有等级(grade)需求,但目前表结构不支持 - 活动藏品需要独立的生命周期管理 - 前端封面 URL 逐个解析(`Promise.all` + 多次 OSS 预签名调用),性能差 - 前端多重生命周期触发(`onMounted` + `onActivated` + `onWatch` + `onShow`),存在重复请求 ### 1.2 重构目标 1. 建立普通藏品、典藏藏品、活动藏品三套并行的数据表结构 2. 普通藏品按 grade(1/2/3)分组展示,典藏按 category 子分类分组,活动按 activity_type 分组 3. 通过 `asset_registry` 统一索引表实现跨类型统一查询 4. 后端批量返回预签名封面 URL,前端零额外 OSS 请求 5. 星册页面按类型 + 子分类/等级分组展示,每组最多展示 6 条 6. "查看更多" 跳转独立页面,支持分页 7. 普通藏品(铸爱流程)、典藏藏品、活动藏品均可放入星册 --- ## 二、数据模型 ### 2.1 现有表(不变动) | 表名 | 说明 | |------|------| | `assets` | 普通藏品(铸爱流程),结构不变 | **说明:** `cover_url_signed`(预签名URL)不在数据库中存储,由 Service 层在响应时动态生成(调用OSS生成预签名URL),避免URL过期问题。 ### 2.2 新建表 #### 2.2.1 典藏藏品表 `collection_assets` ```sql CREATE TABLE collection_assets ( id BIGSERIAL PRIMARY KEY, asset_id BIGINT UNIQUE NOT NULL, -- 关联 assets 表主键 owner_uid BIGINT NOT NULL, star_id BIGINT NOT NULL, name VARCHAR(100) NOT NULL, cover_url VARCHAR(500) NOT NULL, category VARCHAR(50) NOT NULL, -- 典藏子分类(动态值,示例:'limited', 'classic') like_count INT NOT NULL DEFAULT 0, status SMALLINT NOT NULL DEFAULT 0, -- 0=Pending, 1=Active metadata JSONB, -- 预留扩展字段 created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, CONSTRAINT uk_owner_star_name UNIQUE (owner_uid, star_id, name) ); CREATE INDEX idx_collection_owner_star ON collection_assets (owner_uid, star_id); CREATE INDEX idx_collection_category ON collection_assets (category); CREATE INDEX idx_collection_asset_id ON collection_assets (asset_id); ``` #### 2.2.2 活动藏品表 `activity_assets` ```sql CREATE TABLE activity_assets ( id BIGSERIAL PRIMARY KEY, asset_id BIGINT UNIQUE NOT NULL, owner_uid BIGINT NOT NULL, star_id BIGINT NOT NULL, activity_id BIGINT NOT NULL, -- 所属活动 ID activity_type VARCHAR(50) NOT NULL, -- 活动类型(动态值,示例:'birthday', 'anniversary', 'concert') name VARCHAR(100) NOT NULL, cover_url VARCHAR(500) NOT NULL, like_count INT NOT NULL DEFAULT 0, status SMALLINT NOT NULL DEFAULT 0, metadata JSONB, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, CONSTRAINT uk_owner_activity_name UNIQUE (owner_uid, activity_id, name) ); CREATE INDEX idx_activity_owner ON activity_assets (owner_uid, activity_id); CREATE INDEX idx_activity_star ON activity_assets (star_id, activity_id); CREATE INDEX idx_activity_type ON activity_assets (activity_type); CREATE INDEX idx_activity_asset_id ON activity_assets (asset_id); ``` #### 2.2.3 统一索引表 `asset_registry` ```sql CREATE TABLE asset_registry ( id BIGSERIAL PRIMARY KEY, asset_id BIGINT NOT NULL, asset_type VARCHAR(20) NOT NULL, -- 'regular' | 'collection' | 'activity' owner_uid BIGINT NOT NULL, star_id BIGINT NOT NULL, -- 普通藏品专属字段(其他类型时为 NULL) grade SMALLINT, -- 普通藏品等级:1 / 2 / 3(仅 regular 时有效) -- 典藏专属字段(其他类型时为 NULL) collection_category VARCHAR(50), -- 典藏子分类(仅 collection 时有效) -- 活动专属字段(其他类型时为 NULL) activity_id BIGINT, activity_type VARCHAR(50), -- 公共字段 status SMALLINT NOT NULL DEFAULT 0, like_count INT NOT NULL DEFAULT 0, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, CONSTRAINT uk_asset_type_id UNIQUE (asset_type, asset_id), CONSTRAINT uk_owner_star_type_asset UNIQUE (owner_uid, star_id, asset_type, asset_id) ); CREATE INDEX idx_registry_owner_star ON asset_registry (owner_uid, star_id); CREATE INDEX idx_registry_type_star ON asset_registry (asset_type, star_id); CREATE INDEX idx_registry_star_grade ON asset_registry (star_id, grade) WHERE asset_type = 'regular'; CREATE INDEX idx_registry_star_activity ON asset_registry (star_id, activity_id) WHERE asset_type = 'activity'; ``` **说明:** - `asset_type='regular'`(普通藏品):`grade` 有效(1/2/3),`collection_category` 为 NULL - `asset_type='collection'`(典藏):`collection_category` 有效,`grade` 为 NULL - `asset_type='activity'`(活动藏品):`activity_id` / `activity_type` 有效,`grade` 为 NULL - **同一 `asset_id` 只能属于一种 type**,不能同时注册为 regular 和 collection ### 2.3 设计说明 1. **三种类型的区分方式:** - `regular`(普通藏品):`grade` 有效(1/2/3),由点赞数计算得出,预留升级接口 - `collection`(典藏):`collection_category` 有效(动态字符串),无 grade - `activity`(活动藏品):`activity_id` / `activity_type` 有效,无 grade 2. **category / activity_type 为动态字符串**,不预设枚举值,应用层负责校验和展示名称映射 3. **registry 表不自增 asset_id**,由各类型表创建时写入,保证主从表一致性 4. **UNIQUE 约束**:`owner_uid + star_id + asset_type + asset_id` 保证用户每种类型下无重复藏品 5. **同一 `asset_id` 只能属于一种 type**,普通藏品、典藏、活动藏品互斥,不能重复注册 6. **`grade` 字段在数据库中为 NULL 表示非普通藏品**,在 API 响应中统一转换为 `0`(由 Gateway 转换层处理) --- ## 三、藏品创建流程 ### 3.1 普通藏品(铸爱流程) ``` 用户上传素材 → 铸造订单创建 → assets 表写入 → 同时写入 asset_registry (type='regular', grade=1) -- grade 初始为1,升级规则预留 ``` 说明:普通藏品(铸爱流程)纳入星册体系,创建时同步写入 registry,`grade` 初始为 1,后续按点赞数升级(规则待确认)。 ### 3.2 典藏藏品 ``` 用户选择"典藏"类型 → 创建 assets 通用记录 → 创建 collection_assets 专属记录(category) → 同时写入 asset_registry (type='collection') ``` ### 3.3 活动藏品 ``` 用户参与活动并获得奖励 → 创建 assets 通用记录 → 创建 activity_assets 专属记录(activity_id, activity_type) → 同时写入 asset_registry (type='activity') ``` ### 3.4 写入一致性保障 建议在同一个事务内完成 assets 写入和 registry 写入,确保一致性。 --- ## 四、普通藏品等级升级机制(预留接口) `grade` 初始值为 1,升级规则待定(由团队讨论确认后实现)。设计上预留扩展接口: ```go // AssetService 或独立的 GradeService 预留方法 UpgradeGrade(assetID int64, fromGrade, toGrade int) error // 升级条件判断后期实现,支持: // - 点赞数阈值触发(如 grade1: ≥0, grade2: ≥100, grade3: ≥500) // - 全网排名触发 // - 手动升级(运营操作) // 升级后同步更新 asset_registry.grade ``` 典藏藏品(collection)无 grade 字段,按 `category` 子分类区分。 --- ## 五、API 设计 ### 5.1 核心原则 - **后端批量返回预签名封面 URL**,前端不单独调用 OSS 预签名接口 - **单次请求返回首页全量数据**,避免前端多次请求 ### 5.2 接口列表 | 接口 | 方法 | 说明 | |------|------|------| | `/api/v1/starbook/home` | GET | 星册首页,按 type → category/grade 分组,含普通/典藏/活动三种类型 | | `/api/v1/starbook/items` | GET | 某分组的藏品列表(分页) | ### 5.3 详细接口定义 #### GET /api/v1/starbook/home **说明:** 获取当前用户在当前 star 下的星册首页数据 **响应示例:** ```json { "code": 200, "data": { "groups": [ { "type": "regular", "category": "castlove", "category_name": "普通", "grades": [ { "grade": 5, "items": [ { "asset_id": 201, "name": "铸爱藏品-A", "cover_url_signed": "https://...", "like_count": 820, "category": "castlove", "grade": 5 } ], "total_count": 3, "has_more": false }, { "grade": 4, "items": [...], "total_count": 8, "has_more": true } ] }, { "type": "collection", "category": "limited", "category_name": "限定典藏", "items": [ { "asset_id": 101, "name": "限定典藏-1", "cover_url_signed": "https://...", "like_count": 820, "category": "limited" } ], "total_count": 5, "has_more": true }, { "type": "activity", "category": "birthday", "category_name": "生日会", "items": [...], "total_count": 2, "has_more": false } ] } } ``` **说明:** - `star_id` 从认证上下文(JWT Token)获取,不在请求参数中显式传递 - `groups`:三种类型的分组逻辑不同 - `type='regular'`(普通藏品):按 `grade` 分组(grade=1/2/3...),`category='castlove'` 固定 - `type='collection'`(典藏):按 `category` 子分类分组,无 grade - `type='activity'`(活动藏品):按 `activity_type` 分组,无 grade - 每组最多返回 6 条,`has_more=true` 表示还有更多(点击"查看更多"跳转分页页) - **Gateway 转换层**:数据库中 `grade` 为 NULL 时,转换为 `0` 返回(非 regular 类型) #### GET /api/v1/starbook/items **Query 参数:** | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | type | string | 是 | `regular` / `collection` / `activity` | | category | string | 否 | 子分类,不传则返回该类型全部(regular 时传 `castlove`) | | grade | int | 否 | 等级(仅 regular 时有效,1/2/3...) | | page | int | 否 | 默认 1 | | page_size | int | 否 | 默认 20 | **响应示例:** ```json { "code": 200, "data": { "items": [ { "asset_id": 201, "asset_type": "regular", "name": "铸爱藏品-A", "cover_url_signed": "https://oss-cn-shanghai.../signed", "like_count": 120, "category": "castlove", "grade": 5, "created_at": 1743004800000 } ], "total": 15, "page": 1, "page_size": 20, "has_more": false } } ``` **说明:** - `type='regular'` 时 `grade` 取值 1/2/3...,`category='castlove'` 固定 - `type='collection'` 时无 grade 字段,按 `category` 筛选 - `type='activity'` 时无 grade 字段,按 `activity_type`(即 category 参数)筛选 --- ## 六、后端模块划分 ### 6.1 Service 层 | Service | 职责 | |---------|------| | `AssetService` | 现有流程改造:创建普通藏品时同步写 asset_registry (type='regular', grade=1);预留 grade 升级接口 | | `CollectionService`(新建) | 管理 `collection_assets`,典藏专属逻辑(含 category 子分类) | | `ActivityAssetService`(新建) | 管理 `activity_assets`,活动藏品专属逻辑 | | `StarbookService`(新建) | 星册业务编排,调用各 Service,整合数据,统一生成预签名 URL | ### 6.2 StarbookService 核心逻辑 1. 从 `asset_registry` 查询当前用户 + 当前 star 下的所有藏品(regular + collection + activity) 2. 按 type → 各自维度分组(regular 按 grade,collection 按 category,activity 按 activity_type) 3. 批量调用 OSS 生成预签名 URL(已有 `generatePresignedURL` 逻辑复用) 4. 返回结构化数据 ### 6.3 Repository 层 | Repository | 表 | |------------|-----| | `CollectionRepository` | `collection_assets` | | `ActivityAssetRepository` | `activity_assets` | | `AssetRegistryRepository` | `asset_registry` | --- ## 七、Proto 文件变更 ### 7.1 新建 starbook.proto ```protobuf syntax = "proto3"; package topfans.starbook; option go_package = "github.com/topfans/backend/pkg/proto/starbook;starbook"; import "proto/common.proto"; import "google/api/annotations.proto"; service StarbookService { // 星册首页 rpc GetStarbookHome(GetStarbookHomeRequest) returns (GetStarbookHomeResponse) { option (google.api.http) = { get: "/api/v1/starbook/home" }; } // 藏品列表(分页) rpc GetStarbookItems(GetStarbookItemsRequest) returns (GetStarbookItemsResponse) { option (google.api.http) = { get: "/api/v1/starbook/items" }; } } message GetStarbookHomeRequest {} message GetStarbookHomeResponse { topfans.common.BaseResponse base = 1; StarbookHomeData data = 2; } message StarbookHomeData { repeated AssetGroup groups = 1; } message AssetGroup { string type = 1; // 'regular' / 'collection' / 'activity' string category = 2; // 'castlove'(regular) / collection_category / activity_type string category_name = 3; // regular 使用 grades 分组;collection/activity 使用 flat items 列表 repeated GradeSection grades = 4; // 仅 regular 时有效 repeated AssetItem items = 5; // collection / activity 时有效 int32 total_count = 6; bool has_more = 7; } message GradeSection { int32 grade = 1; // 仅 regular 时使用 repeated AssetItem items = 2; int32 total_count = 3; bool has_more = 4; } message AssetItem { int64 asset_id = 1; string name = 2; string cover_url_signed = 3; int32 like_count = 4; int64 created_at = 5; string category = 6; // regular: 'castlove' / collection: category / activity: activity_type int32 grade = 7; // 仅 regular 时有效(1/2/3...),其他类型为 0 } message GetStarbookItemsRequest { string type = 1; // 'regular' / 'collection' / 'activity' string category = 2; // regular 时固定传 'castlove' int32 grade = 3; // 仅 regular 时有效(1/2/3...) int32 page = 4; int32 page_size = 5; } message GetStarbookItemsResponse { topfans.common.BaseResponse base = 1; AssetListData data = 2; } message AssetListData { repeated AssetItem items = 1; int64 total = 2; int32 page = 3; int32 page_size = 4; bool has_more = 5; } ``` --- ## 八、前端重构 ### 8.1 生命周期优化 **问题:** 当前 `onMounted` + `onActivated` + `onShow` + `watch(isActive)` 四处触发 `loadAssetsList()` **解决:** ```js // 只保留 onShow + watch isActive,合并为单一加载逻辑 let lastLoadedAt = 0; onShow(() => { if (props.isActive) { loadStarbookData(); } }); watch(() => props.isActive, (newVal) => { if (newVal) loadStarbookData(); }); // 限制频繁刷新:距离上次加载不足 1 秒则跳过 function loadStarbookData() { const now = Date.now(); if (now - lastLoadedAt < 1000) return; lastLoadedAt = now; // ... 实际加载逻辑 } ``` ### 8.2 数据流变更 | 变更前 | 变更后 | |--------|--------| | 调用 `getMyAssetsApi`(普通藏品) | 调用 `getStarbookHomeApi`(统一,含 regular/collection/activity) | | 手动解析封面 URL(`Promise.all` 逐个调 OSS) | 后端直接返回 `cover_url_signed` | | 前端按 index 硬分组 | 后端按 type → category → grade 分组返回 | | 普通藏品不展示在星册 | 普通藏品(type='regular')纳入星册体系 | ### 8.3 页面路由 | 页面 | 路由 | 说明 | |------|------|------| | 星册主页 | `/pages/starbook/index` | 不变 | | 查看更多 | `/pages/starbook/items?type=regular&category=castlove&grade=5` | 新页面,接分页数据 | ### 8.4 数据结构(前端 TypeScript 类型) ```ts interface StarbookHomeData { groups: AssetGroup[] } interface AssetGroup { type: 'regular' | 'collection' | 'activity' category: string // regular: 'castlove' / collection: 子分类 / activity: activity_type category_name: string // regular 按 grade 分组,collection/activity 直接用 items 列表 grades?: GradeSection[] // 仅 regular 时有效 items?: AssetItem[] // collection / activity 时有效 total_count: number has_more: boolean } interface GradeSection { grade: number // regular: 1/2/3... items: AssetItem[] total_count: number has_more: boolean } interface AssetItem { asset_id: number name: string cover_url_signed: string like_count: number created_at: number category: string // regular: 'castlove' / collection: 子分类 / activity: activity_type grade: number // regular: 1/2/3...,其他类型为 0 } // grade 中文转换函数(前端使用) function toChineseGrade(grade: number): string { const map = { 1: '一', 2: '二', 3: '三', 4: '四', 5: '五' }; return `等级${map[grade] || grade}`; } ``` **说明:** - `type='regular'`:`grades[]` 分组(按 grade),`grade` 取值 1/2/3...,`category='castlove'` 固定 - `type='collection'`:`items[]` 列表(按 category 分组),`grade=0`(数据库存 NULL,API 转换层统一返回 0),`category` 为典藏子分类 - `type='activity'`:`items[]` 列表(按 activity_type 分组),`grade=0`(数据库存 NULL,API 转换层统一返回 0),`category` 为活动类型 --- ## 九、性能保障 1. **后端批量预签名**:单次请求一次性生成所有封面 URL 的预签名,前端零额外请求 2. **Registry 单表索引**:三条索引覆盖所有查询路径(owner+star, type+star, star+grade[regular]) 3. **首页按组截断**:每 grade 组最多返回 6 条,`has_more` 标识是否还有更多 4. **前端防抖**:1 秒内重复触发只执行一次加载 5. **分类动态扩展**:category / activity_type 不写死枚举,应用层通过配置或枚举接口获取展示名 --- ## 十、待确认事项 | 事项 | 状态 | 说明 | |------|------|------| | 典藏子分类(category)枚举值 | 待确认 | 动态字符串,设计已支持 | | 活动类型(activity_type)枚举值 | 待确认 | 动态字符串,设计已支持 | | 普通藏品 grade 升级规则 | 待确认 | 设计已预留 `UpgradeGrade` 接口 | | 普通藏品 category 固定值 | 待确认 | 建议 `category='castlove'` | --- ## 十一、影响范围 | 范围 | 变更内容 | |------|----------| | 数据库 | 新建 3 张表(collection_assets, activity_assets, asset_registry) | | Proto | 新建 starbook.proto | | Gateway | 新建 StarbookController,新增 2 个路由 | | Service 层 | 新建 CollectionService, ActivityAssetService, StarbookService | | Repository 层 | 新建 CollectionRepository, ActivityAssetRepository, AssetRegistryRepository | | 前端 | StarbookContent.vue 重构,新增 /pages/starbook/items.vue | | 铸爱流程 | **受影响**,普通藏品创建时需同步写入 asset_registry(type='regular', grade=1)|