28 KiB
28 KiB
典藏/活动藏品体系 + 星册重构设计
日期: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 重构目标
- 建立原创藏品、典藏藏品、活动藏品三套并行的数据表结构
- 原创藏品按 grade(1/2/3)分组展示,典藏按 category 子分类分组,活动按 activity_type 分组
- 通过
asset_registry统一索引表实现跨类型统一查询 - 后端直接返回 OSS 公共 URL,前端通过该 URL 直接访问 OSS 资源查看封面
- 星册页面按类型 + 子分类/等级分组展示,每组最多展示 6 条
- "查看更多" 跳转独立页面,支持分页
- 原创藏品(铸爱流程)、典藏藏品、活动藏品均可放入星册
二、数据模型
2.1 现有表(不变动)
| 表名 | 说明 |
|---|---|
assets |
原创藏品(铸爱流程),结构不变 |
说明: cover_url(OSS 公共 URL)直接存储在数据库中,前端可直接访问。
2.2 新建表
2.2.1 典藏藏品表 collection_assets
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
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
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为 NULLasset_type='collection'(典藏):collection_category有效,grade为 NULLasset_type='activity'(活动藏品):activity_id/activity_type有效,grade为 NULL- 同一
asset_id只能属于一种 type,不能同时注册为 regular 和 collection
2.3 设计说明
- 三种类型的区分方式:
regular(原创藏品):grade有效(1/2/3),由点赞数计算得出,预留升级接口collection(典藏):collection_category有效(动态字符串),无 gradeactivity(活动藏品):activity_id/activity_type有效,无 grade
- category / activity_type 为动态字符串,不预设枚举值,应用层负责校验和展示名称映射
- registry 表不自增 asset_id,由各类型表创建时写入,保证主从表一致性
- UNIQUE 约束:
owner_uid + star_id + asset_type + asset_id保证用户每种类型下无重复藏品 - 同一
asset_id只能属于一种 type,原创藏品、典藏、活动藏品互斥,不能重复注册 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,升级规则待定(由团队讨论确认后实现)。设计上预留扩展接口:
// AssetService 或独立的 GradeService 预留方法
UpgradeGrade(assetID int64, fromGrade, toGrade int) error
// 升级条件判断后期实现,支持:
// - 点赞数阈值触发(如 grade1: ≥0, grade2: ≥100, grade3: ≥500)
// - 全网排名触发
// - 手动升级(运营操作)
// 升级后同步更新 asset_registry.grade
典藏藏品(collection)无 grade 字段,按 category 子分类区分。
五、API 设计
5.1 核心原则
- 后端直接返回 OSS 公共 URL,前端直接使用该 URL 访问 OSS 资源(无需调用 OSS 预签名接口)
- 单次请求返回首页全量数据,避免前端多次请求
5.2 接口列表
| 接口 | 方法 | 说明 |
|---|---|---|
/api/v1/starbook/home |
GET | 星册首页,按 type → category/grade 分组,含原创/典藏/活动三种类型 |
/api/v1/starbook/items |
GET | 某分组的藏品列表(分页) |
5.3 详细接口定义
GET /api/v1/starbook/home
说明: 获取当前用户在当前 star 下的星册首页数据
响应示例:
{
"code": 200,
"data": {
"groups": [
{
"type": "regular",
"category": "castlove",
"category_name": "原创",
"grades": [
{
"grade": 5,
"items": [
{ "asset_id": 201, "name": "铸爱藏品-A", "cover_url": "https://bucket.oss-cn-shanghai.aliyuncs.com/xxx.jpg", "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": "https://bucket.oss-cn-shanghai.aliyuncs.com/xxx.jpg", "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子分类分组,无 gradetype='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 |
响应示例:
{
"code": 200,
"data": {
"items": [
{
"asset_id": 201,
"asset_type": "regular",
"name": "铸爱藏品-A",
"cover_url": "https://bucket.oss-cn-shanghai.aliyuncs.com/xxx.jpg",
"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,整合数据,返回 OSS URL |
6.2 StarbookService 核心逻辑
- 从
asset_registry查询当前用户 + 当前 star 下的所有藏品(regular + collection + activity) - 按 type → 各自维度分组(regular 按 grade,collection 按 category,activity 按 activity_type)
- 直接返回 OSS URL(无需调用 OSS 预签名服务)
- 返回结构化数据
6.3 Repository 层
| Repository | 表 |
|---|---|
CollectionRepository |
collection_assets |
ActivityAssetRepository |
activity_assets |
AssetRegistryRepository |
asset_registry |
七、Proto 文件变更
7.1 新建 starbook.proto
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 = 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()
解决:
// 只保留 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 |
| 前端按 index 硬分组 | 后端按 type → category → grade 分组返回 |
| 原创藏品不展示在星册 | 原创藏品(type='regular')纳入星册体系 |
8.3 页面路由
| 页面 | 路由 | 说明 |
|---|---|---|
| 星册主页 | /pages/starbook/index |
不变 |
| 查看更多 | /pages/starbook/items?type=regular&category=castlove&grade=5 |
新页面,接分页数据 |
8.4 数据结构(前端 JavaScript)
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: 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) {
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为活动类型
九、性能保障
- 后端直接返回 OSS URL:无需调用 OSS 预签名服务,前端直接用该 URL 访问 OSS 资源
- Registry 单表索引:三条索引覆盖所有查询路径(owner+star, type+star, star+grade[regular])
- 首页按组截断:每 grade 组最多返回 6 条,
has_more标识是否还有更多 - 前端防抖:1 秒内重复触发只执行一次加载
- 分类动态扩展: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) |