docs: fix starbook refactor spec after review

- Fix category_name from "典藏" to "普通" for regular type
- Clarify 6 items max display (更多> not counted)
- Document cover_url_signed is generated on-the-fly, not stored
- Add star_id comes from JWT context note
- Document Gateway NULL→0 conversion for grade
- Add toChineseGrade helper function for frontend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zheng020 2026-04-13 19:10:57 +08:00
parent 11bcfcd68b
commit 4e2e867c06

View File

@ -1,11 +1,137 @@
# 典藏/活动藏品体系 + 星册重构设计
> 日期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 现状问题
@ -38,6 +164,8 @@
|------|------|
| `assets` | 普通藏品(铸爱流程),结构不变 |
**说明:** `cover_url_signed`预签名URL不在数据库中存储由 Service 层在响应时动态生成调用OSS生成预签名URL避免URL过期问题。
### 2.2 新建表
#### 2.2.1 典藏藏品表 `collection_assets`
@ -144,38 +272,6 @@ CREATE INDEX idx_registry_star_activity ON asset_registry (star_id, activity_id)
5. **同一 `asset_id` 只能属于一种 type**,普通藏品、典藏、活动藏品互斥,不能重复注册
6. **`grade` 字段在数据库中为 NULL 表示非普通藏品**,在 API 响应中统一转换为 `0`(由 Gateway 转换层处理)
### 2.4 星册槽位表 `starbook_slots`
新建独立表与展馆GalleryService`booth_slots` 完全分离:
```sql
CREATE TABLE starbook_slots (
id BIGSERIAL PRIMARY KEY,
owner_uid BIGINT NOT NULL,
star_id BIGINT NOT NULL,
slot_index SMALLINT NOT NULL, -- 槽位序号(从 1 开始)
asset_id BIGINT, -- NULL 表示空槽位
asset_type VARCHAR(20), -- 'regular' | 'collection' | 'activity',与 registry 对应
placed_at BIGINT NOT NULL, -- 放置时间(毫秒时间戳)
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT uk_owner_star_slot UNIQUE (owner_uid, star_id, slot_index),
CONSTRAINT fk_starbook_asset FOREIGN KEY (asset_id, asset_type)
REFERENCES asset_registry(asset_id, asset_type) ON DELETE SET NULL
);
CREATE INDEX idx_starbook_owner_star ON starbook_slots (owner_uid, star_id);
CREATE INDEX idx_starbook_asset ON starbook_slots (asset_id, asset_type);
```
**说明:**
- `slot_index` 从 1 开始,上限由 `fan_profiles.starbook_limit` 控制(动态,非写死)
- `total_slots``fan_profiles.starbook_limit` 动态读取,而非写死 15
- 槽位数量支持动态扩展(运营可调整 starbook_limit
- 外键约束 `asset_id + asset_type` 关联 `asset_registry`,确保只有注册过的藏品才能放入星册
- **与 booth_slots 完全独立**,星册槽位只用于星册页面展示
---
## 三、藏品创建流程
@ -241,16 +337,14 @@ UpgradeGrade(assetID int64, fromGrade, toGrade int) error
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/v1/starbook/home` | GET | 星册首页,按 type → categorygrade 分组,含普通/典藏/活动三种类型 |
| `/api/v1/starbook/home` | GET | 星册首页,按 type → category/grade 分组,含普通/典藏/活动三种类型 |
| `/api/v1/starbook/items` | GET | 某分组的藏品列表(分页) |
| `/api/v1/starbook/place` | POST | 放置藏品到星册槽位 |
| `/api/v1/starbook/slots/{slot_index}` | DELETE | 从槽位移除 |
### 5.3 详细接口定义
#### GET /api/v1/starbook/home
**说明:** 获取当前用户在当前 star 下的星册首页数据。`total_slots` 从 `fan_profiles.starbook_limit` 动态读取。
**说明:** 获取当前用户在当前 star 下的星册首页数据
**响应示例:**
@ -258,45 +352,23 @@ UpgradeGrade(assetID int64, fromGrade, toGrade int) error
{
"code": 200,
"data": {
"total_slots": 3,
"slots": [
{
"slot_index": 1,
"asset": null,
"locked": false
},
{
"slot_index": 2,
"asset": {
"asset_id": 101,
"asset_type": "collection",
"name": "限定典藏-1",
"cover_url_signed": "https://oss-cn-shanghai.../signed",
"like_count": 820,
"category": "limited"
},
"locked": false
}
],
"groups": [
{
"type": "regular",
"category": "castlove",
"category_name": "典藏",
"category_name": "普通",
"grades": [
{
"grade": 3,
"grade": 5,
"items": [
{ "asset_id": 201, "name": "铸爱藏品-A", "cover_url_signed": "https://...", "like_count": 820, "category": "castlove", "grade": 3 }
{ "asset_id": 201, "name": "铸爱藏品-A", "cover_url_signed": "https://...", "like_count": 820, "category": "castlove", "grade": 5 }
],
"total_count": 3,
"has_more": false
},
{
"grade": 2,
"items": [
{ "asset_id": 202, "name": "铸爱藏品-B", "cover_url_signed": "https://...", "like_count": 450, "category": "castlove", "grade": 2 }
],
"grade": 4,
"items": [...],
"total_count": 8,
"has_more": true
}
@ -312,14 +384,6 @@ UpgradeGrade(assetID int64, fromGrade, toGrade int) error
"total_count": 5,
"has_more": true
},
{
"type": "collection",
"category": "classic",
"category_name": "经典典藏",
"items": [...],
"total_count": 3,
"has_more": false
},
{
"type": "activity",
"category": "birthday",
@ -334,22 +398,23 @@ UpgradeGrade(assetID int64, fromGrade, toGrade int) error
```
**说明:**
- `total_slots`:从 `fan_profiles.starbook_limit` 动态读取,非写死
- `slots`:星册槽位,当前展示的藏品直接返回预签名 URL`placed_at` 为放置时间
- `star_id` 从认证上下文JWT Token获取不在请求参数中显式传递
- `groups`:三种类型的分组逻辑不同
- `type='regular'`(普通藏品):按 `grade` 分组grade=1/2/3`category='castlove'` 固定
- `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 |
| grade | int | 否 | 等级(仅 regular 时有效1/2/3... |
| page | int | 否 | 默认 1 |
| page_size | int | 否 | 默认 20 |
@ -367,7 +432,7 @@ UpgradeGrade(assetID int64, fromGrade, toGrade int) error
"cover_url_signed": "https://oss-cn-shanghai.../signed",
"like_count": 120,
"category": "castlove",
"grade": 2,
"grade": 5,
"created_at": 1743004800000
}
],
@ -380,57 +445,10 @@ UpgradeGrade(assetID int64, fromGrade, toGrade int) error
```
**说明:**
- `type='regular'``grade` 取值 1/2/3`category='castlove'` 固定
- `type='regular'``grade` 取值 1/2/3...`category='castlove'` 固定
- `type='collection'` 时无 grade 字段,按 `category` 筛选
- `type='activity'` 时无 grade 字段,按 `activity_type`(即 category 参数)筛选
#### POST /api/v1/starbook/place
**请求:**
```json
{
"asset_id": 101,
"asset_type": "collection",
"slot_index": 1
}
```
**说明:** 增加 `asset_type` 字段,用于后端定位 registry 中的具体记录。
**响应:**
```json
{
"code": 200,
"data": {
"slot_index": 1,
"asset": {
"asset_id": 101,
"asset_type": "collection",
"name": "限定典藏-1",
"cover_url_signed": "https://oss-cn-shanghai.../signed",
"like_count": 820
}
}
}
```
#### DELETE /api/v1/starbook/slots/{slot_index}
**说明:** 从指定槽位移除藏品
**响应:**
```json
{
"code": 200,
"data": {
"slot_index": 1
}
}
```
---
## 六、后端模块划分
@ -442,16 +460,14 @@ UpgradeGrade(assetID int64, fromGrade, toGrade int) error
| `AssetService` | 现有流程改造:创建普通藏品时同步写 asset_registry (type='regular', grade=1);预留 grade 升级接口 |
| `CollectionService`(新建) | 管理 `collection_assets`,典藏专属逻辑(含 category 子分类) |
| `ActivityAssetService`(新建) | 管理 `activity_assets`,活动藏品专属逻辑 |
| `StarbookService`(新建) | 星册业务编排,管理 `starbook_slots` 表,调用各 Service整合数据统一生成预签名 URL |
| `StarbookService`(新建) | 星册业务编排,调用各 Service整合数据统一生成预签名 URL |
### 6.2 StarbookService 核心逻辑
1. 从 `fan_profiles.starbook_limit` 获取当前用户的槽位上限 `total_slots`
2. 从 `starbook_slots` 读取已放置的藏品,填充 `slots` 数组
3. 从 `asset_registry` 查询当前用户 + 当前 star 下的所有藏品regular + collection + activity
4. 按 type → 各自维度分组regular 按 gradecollection 按 categoryactivity 按 activity_type
5. 批量调用 OSS 生成预签名 URL已有 `generatePresignedURL` 逻辑复用)
6. 返回结构化数据
1. 从 `asset_registry` 查询当前用户 + 当前 star 下的所有藏品regular + collection + activity
2. 按 type → 各自维度分组regular 按 gradecollection 按 categoryactivity 按 activity_type
3. 批量调用 OSS 生成预签名 URL已有 `generatePresignedURL` 逻辑复用)
4. 返回结构化数据
### 6.3 Repository 层
@ -491,21 +507,6 @@ service StarbookService {
get: "/api/v1/starbook/items"
};
}
// 放置藏品到槽位
rpc PlaceToSlot(PlaceToSlotRequest) returns (PlaceToSlotResponse) {
option (google.api.http) = {
post: "/api/v1/starbook/place"
body: "*"
};
}
// 从槽位移除
rpc RemoveFromSlot(RemoveFromSlotRequest) returns (RemoveFromSlotResponse) {
option (google.api.http) = {
delete: "/api/v1/starbook/slots/{slot_index}"
};
}
}
message GetStarbookHomeRequest {}
@ -516,26 +517,7 @@ message GetStarbookHomeResponse {
}
message StarbookHomeData {
int32 total_slots = 1;
repeated SlotInfo slots = 2;
repeated AssetGroup groups = 3;
}
message SlotInfo {
int32 slot_index = 1;
bool locked = 2;
SlotAsset asset = 3;
}
message SlotAsset {
int64 asset_id = 1;
string asset_type = 2; // 'regular' / 'collection' / 'activity'
string name = 3;
string cover_url_signed = 4;
int32 like_count = 5;
string category = 6; // regular: 'castlove' / collection: category / activity: activity_type
int32 grade = 7; // 仅 regular 时有效1/2/3其他类型为 0
int64 placed_at = 8; // 放置时间(毫秒时间戳)
repeated AssetGroup groups = 1;
}
message AssetGroup {
@ -544,7 +526,7 @@ message AssetGroup {
string category_name = 3;
// regular 使用 grades 分组collection/activity 使用 flat items 列表
repeated GradeSection grades = 4; // 仅 regular 时有效
repeated AssetItem items = 5; // collection / activity 使用
repeated AssetItem items = 5; // collection / activity 时有效
int32 total_count = 6;
bool has_more = 7;
}
@ -563,13 +545,13 @@ message AssetItem {
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
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 grade = 3; // 仅 regular 时有效1/2/3...
int32 page = 4;
int32 page_size = 5;
}
@ -586,25 +568,6 @@ message AssetListData {
int32 page_size = 4;
bool has_more = 5;
}
message PlaceToSlotRequest {
int64 asset_id = 1;
string asset_type = 2; // 'regular' / 'collection' / 'activity'
int32 slot_index = 3;
}
message PlaceToSlotResponse {
topfans.common.BaseResponse base = 1;
SlotInfo slot = 2;
}
message RemoveFromSlotRequest {
int32 slot_index = 1;
}
message RemoveFromSlotResponse {
topfans.common.BaseResponse base = 1;
}
```
---
@ -646,7 +609,6 @@ function loadStarbookData() {
|--------|--------|
| 调用 `getMyAssetsApi`(普通藏品) | 调用 `getStarbookHomeApi`(统一,含 regular/collection/activity |
| 手动解析封面 URL`Promise.all` 逐个调 OSS | 后端直接返回 `cover_url_signed` |
| 前端硬编码 15 槽位 | 后端返回 `total_slots`(从 `fan_profiles.starbook_limit` 动态读取) |
| 前端按 index 硬分组 | 后端按 type → category → grade 分组返回 |
| 普通藏品不展示在星册 | 普通藏品type='regular')纳入星册体系 |
@ -655,34 +617,15 @@ function loadStarbookData() {
| 页面 | 路由 | 说明 |
|------|------|------|
| 星册主页 | `/pages/starbook/index` | 不变 |
| 查看更多 | `/pages/starbook/items?type=collection&category=limited&grade=3` | 新页面,接分页数据 |
| 查看更多 | `/pages/starbook/items?type=regular&category=castlove&grade=5` | 新页面,接分页数据 |
### 8.4 数据结构(前端 TypeScript 类型)
```ts
interface StarbookHomeData {
total_slots: number // 从 fan_profiles.starbook_limit 动态读取
slots: SlotInfo[]
groups: AssetGroup[]
}
interface SlotInfo {
slot_index: number
locked: boolean
asset: SlotAsset | null
}
interface SlotAsset {
asset_id: number
asset_type: 'regular' | 'collection' | 'activity'
name: string
cover_url_signed: string
like_count: number
category: string
grade: number // regular: 1/2/3其他类型为 0
placed_at: number // 放置时间(毫秒时间戳)
}
interface AssetGroup {
type: 'regular' | 'collection' | 'activity'
category: string // regular: 'castlove' / collection: 子分类 / activity: activity_type
@ -695,7 +638,7 @@ interface AssetGroup {
}
interface GradeSection {
grade: number // regular: 1/2/3
grade: number // regular: 1/2/3...
items: AssetItem[]
total_count: number
has_more: boolean
@ -708,12 +651,18 @@ interface AssetItem {
like_count: number
created_at: number
category: string // regular: 'castlove' / collection: 子分类 / activity: activity_type
grade: number // regular: 1/2/3其他类型为 0
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='regular'``grades[]` 分组(按 grade`grade` 取值 1/2/3...`category='castlove'` 固定
- `type='collection'``items[]` 列表(按 category 分组),`grade=0`(数据库存 NULLAPI 转换层统一返回 0`category` 为典藏子分类
- `type='activity'``items[]` 列表(按 activity_type 分组),`grade=0`(数据库存 NULLAPI 转换层统一返回 0`category` 为活动类型
@ -744,10 +693,10 @@ interface AssetItem {
| 范围 | 变更内容 |
|------|----------|
| 数据库 | 新建 4 张表collection_assets, activity_assets, asset_registry, starbook_slots |
| 数据库 | 新建 3 张表collection_assets, activity_assets, asset_registry |
| Proto | 新建 starbook.proto |
| Gateway | 新建 StarbookController新增 4 个路由 |
| Gateway | 新建 StarbookController新增 2 个路由 |
| Service 层 | 新建 CollectionService, ActivityAssetService, StarbookService |
| Repository 层 | 新建 CollectionRepository, ActivityAssetRepository, AssetRegistryRepository, StarbookSlotRepository |
| Repository 层 | 新建 CollectionRepository, ActivityAssetRepository, AssetRegistryRepository |
| 前端 | StarbookContent.vue 重构,新增 /pages/starbook/items.vue |
| 铸爱流程 | **受影响**,普通藏品创建时需同步写入 asset_registrytype='regular', grade=1|