topfans/docs/superpowers/specs/2026-04-27-inspiration-flow-design.md
2026-04-29 11:19:35 +08:00

698 lines
20 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.

# 灵感瀑布流Inspiration Flow设计文档
> **创建日期:** 2026-04-28
> **更新日期:** 2026-04-29
> **项目:** TopFans 横向瀑布流藏品展示
> **服务:** galleryService (Go Dubbo-go)
> **状态:** 设计中
---
## 一、设计目标
横向瀑布流展示该 star_id 下所有用户展出的藏品,支持**随机展示**、**双向横向无限滚动**、按类型过滤。
**核心特点:**
- 每次查询返回随机顺序的藏品
- 支持双向横向无限滚动:
- 向右滚动:加载更多新数据(发现新内容)
- 向左滚动:加载历史数据(回看)
- 支持按藏品类型过滤badge/poster/original/all
- 会话级缓存,刷新后数据重新随机
**无限滚动实现方式:** 使用游标分页Cursor-based Pagination前端滚动到边缘时携带 `cursor` 参数加载下一批数据。
---
## 二、数据来源
**主表:** Exhibition展品展示表
**关联表:**
- Asset资产表- 用于获取藏品名称、封面、点赞数
- FanProfile粉丝档案表- 用于获取展出者昵称
**筛选条件:**
- `occupier_star_id = ?` (当前用户 star_id)
- `expire_at > now` (未过期)
- `deleted_at IS NULL` (未删除)
---
## 三、API 设计
### 3.1 获取灵感瀑布藏品列表
```
GET /api/v1/inspiration-flow
```
**Query 参数:**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| cursor | string | 否 | 空 | 游标(首次请求为空) |
| direction | string | 否 | right | 滚动方向right加载新数据/ left加载历史 |
| limit | int | 否 | 10 | 每页数量(最大 20移动端优化 |
| type | string | 否 | all | 过滤类型badge/poster/original/all |
**HTTP 响应:**
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [
{
"asset_id": 123,
"name": "藏品名称",
"cover_url": "https://xxx.com/cover.png",
"like_count": 100,
"owner_nickname": "粉丝昵称"
}
],
"cursor": "eyJsaW1pdCI6MTB9",
"has_more": true
}
}
```
**响应字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| items | array | 藏品列表 |
| cursor | string | 下次请求的游标base64 编码的 JSON |
| has_more | bool | 是否还有更多数据 |
**错误码:**
| code | 说明 |
|------|------|
| 200 | 成功 |
| 401 | 用户认证失败 |
| 500 | 服务器内部错误 |
---
## 四、Proto 定义
### 4.1 Request / Response
```protobuf
// 获取灵感瀑布藏品列表请求
message GetInspirationFlowRequest {
string cursor = 1; // 游标(首次请求为空)
string direction = 2; // 滚动方向right加载新数据/ left加载历史
int32 limit = 3; // 每页数量默认10最大20
string type = 4; // 过滤类型badge/poster/original/all默认all
}
// 获取灵感瀑布藏品列表响应
message GetInspirationFlowResponse {
topfans.common.BaseResponse base = 1;
InspirationFlowData data = 2;
}
// 灵感瀑布数据
message InspirationFlowData {
repeated InspirationFlowItem items = 1; // 藏品列表
string cursor = 2; // 下次请求的游标
bool has_more = 3; // 是否有更多
}
// 灵感瀑布藏品项
message InspirationFlowItem {
int64 asset_id = 1; // 资产ID
string name = 2; // 藏品名称
string cover_url = 3; // 封面图URL
int32 like_count = 4; // 点赞数
string owner_nickname = 5; // 展出者昵称
}
```
### 4.2 Service 方法
在 GalleryService 中新增方法:
```protobuf
// 展馆服务
service GalleryService {
// ... 现有方法 ...
// 获取灵感瀑布藏品列表
rpc GetInspirationFlow(GetInspirationFlowRequest) returns (GetInspirationFlowResponse) {
option (google.api.http) = {
get: "/api/v1/inspiration-flow"
};
}
}
```
---
## 五、核心逻辑
### 5.1 随机查询实现
**核心需求:** 每次查询返回随机顺序的藏品数据,而不是固定顺序。
**实现方式:** 使用 PostgreSQL 的 `ORDER BY RANDOM()` 实现随机排序。
**为什么选择 ORDER BY RANDOM()**
1. **实现简单**:一条 SQL 搞定,无需应用层处理
2. **数据量适配良好**:在几千到几万条数据时性能可接受(< 100ms
3. **接口稳定**便于后期扩展优化策略
**性能说明:**
| 数据量 | RANDOM() 性能 | 推荐程度 |
|--------|--------------|---------|
| < 1万条 | < 50ms | 强烈推荐 |
| 1-5万条 | 50-100ms | 推荐 |
| 5-10万条 | 100-500ms | 可接受 |
| > 10万条 | 开始变慢 | 需要优化 |
> 注:后期数据量超过 10 万条时,可考虑切换为"区间采样"策略,详见本章 5.5 节扩展说明。
---
### 5.2 随机 offset 分页设计
**核心思路:** 每次请求都是独立的随机排序,随机生成 offset 值,而不是依赖游标累加 offset。
**游标结构JSONbase64 编码):**
```json
{
"limit": 10
}
```
> **说明:** 游标只记录 limit不需要记录 offset因为每次 offset 都是随机生成的)
**为什么用随机 offset**
1. **数据变化无影响**:每次都是全新随机,数据变化不影响展示
2. **性能稳定**offset 从 0 开始,不存在深分页性能问题
3. **实现简单**:无需处理数据变化时的缓存问题
**游标的行为说明:**
- 每次请求 offset 都是随机生成的0 ~ max(0, total-limit)
- 同一会话内滚动时,顺序可能跳变(这是预期行为)
- 用户刷新页面后,看到全新的随机顺序
> **业务说明:** 由于每次请求都是独立随机,滚动加载过程中顺序可能跳变。这是预期行为,用户每次刷新看到的是不同顺序,符合"随机展示"的需求。
---
### 5.3 查询逻辑
**实现流程:**
```
1. 查询该 star_id 下有效展品的总数
2. 随机生成 offset 值0 ~ max(0, total-limit)
3. 执行带随机 offset 的查询
```
**SQL 查询:**
```sql
-- 1. 先查询总数
SELECT COUNT(*) FROM exhibitions e
WHERE e.occupier_star_id = ?
AND e.expire_at > ?
AND e.deleted_at IS NULL;
-- 2. 应用层生成随机 offset然后查询
SELECT
e.asset_id,
a.name,
a.cover_url,
a.like_count,
fp.nickname as owner_nickname
FROM exhibitions e
JOIN assets a ON e.asset_id = a.id
JOIN fan_profiles fp ON e.occupier_uid = fp.user_id AND e.occupier_star_id = fp.star_id
WHERE e.occupier_star_id = ?
AND e.expire_at > ?
AND e.deleted_at IS NULL
AND a.status = 1
AND a.is_active = true
AND (? = 'all' OR a.material_type = ?)
ORDER BY RANDOM()
LIMIT ? OFFSET ?; -- offset 由应用层随机生成
```
**参数说明:**
- `? = star_id` (当前用户 star_id)
- `? = now` (当前时间戳)
- `? = type` (过滤类型)
- `? = limit` (每页数量)
- `? = offset` (随机生成的偏移量,非固定值)
---
### 5.4 游标编解码
**说明:** 由于每次请求的 offset 是随机生成的,游标只需要记录 limit。
**编码(服务端):**
```go
limit := 10 // 默认值,实际从请求或配置获取
cursor := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"limit":%d}`, limit)))
```
**解码(服务端):**
```go
decoded, _ := base64.StdEncoding.DecodeString(cursor)
var cursorData map[string]int
json.Unmarshal(decoded, &cursorData)
limit := cursorData["limit"] // offset 由每次请求随机生成,无需从游标获取
```
**前端使用:**
```javascript
// 首次请求
fetch('/api/v1/inspiration-flow?limit=10&type=all')
// 向右滚动(加载新数据)
fetch('/api/v1/inspiration-flow?cursor=eyJsaW1pdCI6MTB9&limit=10&type=all&direction=right')
// 向左滚动(加载历史)
fetch('/api/v1/inspiration-flow?cursor=eyJsaW1pdCI6MTB9&limit=10&type=all&direction=left')
```
---
### 5.5 双向滚动实现
**核心思路:** 后端维护会话级已展示数据缓存,实现双向滚动时完全避免重复。
**滚动方向定义:**
- `direction: "right"` - 向右滚动,加载新数据(发现新内容)
- `direction: "left"` - 向左滚动,加载历史数据(回看)
**游标结构JSONbase64 编码):**
```json
{
"limit": 10
}
```
> **说明:** direction 是独立的 query 参数,不包含在游标中。游标只用于分页控制。
**缓存结构Redis**
```
Key: inspiration_flow:{star_id}:{session_id}
Type: Hash
Fields:
- display_order: [1, 5, 3, 9, 2, 4, ...] # 展示顺序
- history: {1: data1, 5: data5, 3: data3, ...} # 历史数据详情(方便回看)
TTL: 30分钟无操作自动清理
```
**向右滚动处理流程:**
```
1. 前端请求direction=right
2. 后端从 Redis 获取已展示ID集合
3. 后端执行随机查询排除已展示ID
SELECT ... FROM exhibitions
WHERE id NOT IN (已展示ID)
ORDER BY RANDOM()
LIMIT ?
4. 返回新数据,并记录到缓存
5. 前端追加展示
```
**向左滚动处理流程:**
```
1. 前端请求direction=left, offset=?
2. 后端从 Redis 获取 display_order
3. 后端根据 offset 从历史中分页返回
4. 前端在左侧插入展示
```
**SQL 排除已展示ID查询**
```sql
SELECT
e.asset_id,
a.name,
a.cover_url,
a.like_count,
fp.nickname as owner_nickname
FROM exhibitions e
JOIN assets a ON e.asset_id = a.id
JOIN fan_profiles fp ON e.occupier_uid = fp.user_id AND e.occupier_star_id = fp.star_id
WHERE e.occupier_star_id = ?
AND e.expire_at > ?
AND e.deleted_at IS NULL
AND e.id NOT IN (已展示ID列表)
AND a.status = 1
AND a.is_active = true
AND (? = 'all' OR a.material_type = ?)
ORDER BY RANDOM()
LIMIT ?;
```
> **注意:** 当已展示数据量很大时,`NOT IN` 查询性能会下降。需要在适当时机清理已展示缓存(建议 TTL 30分钟或达到一定数量后刷新随机顺序
---
### 5.6 扩展说明:数据量大时的优化策略
**触发条件:** 当展品数据量超过 10 万条,`ORDER BY RANDOM()` 性能开始明显下降时。
**优化方案:区间采样**
**思路:** 把数据按 ID 区间分成多个桶,随机选择桶后在该桶内读取数据,应用层再对结果进行随机打乱
**优点:**
- 查询性能稳定,不随数据量增长而显著下降
- 能保证随机分布,每个数据都有机会被展示
- 跨桶采样,随机性更好
**适用场景:**
- 数据量大10 万以上)
- 需要保证随机分布的均匀性
**实现示意:**
```go
// 1. 获取该 star_id 下有效展品的 ID 范围
minID, maxID := "SELECT MIN(id), MAX(id) FROM exhibitions
WHERE occupier_star_id = ? AND expire_at > ? AND deleted_at IS NULL"
// 2. 计算桶信息(假设每个桶 10000 条)
bucketSize := int64(10000)
totalBuckets := (maxID - minID) / bucketSize + 1
// 3. 随机选择 1 个桶
randomBucket := random.Int63n(totalBuckets)
bucketStartID := minID + randomBucket*bucketSize
bucketEndID := bucketStartID + bucketSize
// 4. 在桶内查询所有有效 ID数据量小随机开销可忽略
bucketIDs := "SELECT id FROM exhibitions
WHERE id BETWEEN ? AND ?
AND occupier_star_id = ? AND expire_at > ? AND deleted_at IS NULL
ORDER BY id"
// 5. 应用层对 bucketIDs 随机打乱,取前 limit 个
shuffle(bucketIDs)
selectedIDs := bucketIDs[:limit]
// 6. 根据 selectedIDs 查询完整数据
"SELECT ... FROM exhibitions WHERE id IN (?)", selectedIDs
```
---
#### 扩展接口设计(预留)
两种策略可以抽象成统一的接口,便于后期切换:
```go
// 随机策略接口
type RandomStrategy interface {
// GetRandomAssetIDs 获取随机的展品 ID 列表
GetRandomAssetIDs(ctx context.Context, starID string, limit int) ([]int64, error)
// Name 返回策略名称
Name() string
}
// 策略1随机排序当前使用数据量 < 10 万时推荐)
type RandomOrderStrategy struct{}
func (s *RandomOrderStrategy) GetRandomAssetIDs(ctx context.Context, starID string, limit int) ([]int64, error) {
// 1. 获取有效展品总数
total, err := repo.CountValidAssets(starID)
if err != nil {
return nil, err
}
if total == 0 {
return []int64{}, nil
}
// 2. 随机生成 offset0 ~ max(0, total-limit)
maxOffset := total - int64(limit)
if maxOffset < 0 {
maxOffset = 0
}
randomOffset := random.Int63n(maxOffset + 1) // +1 是因为 Int63n(0) 会报错
// 3. 执行随机排序查询
return repo.GetRandomAssetsByOrder(starID, int(randomOffset), limit)
}
func (s *RandomOrderStrategy) Name() string {
return "random_order"
}
// 策略2区间采样数据量 > 10 万时推荐)
type RangeSamplingStrategy struct {
bucketSize int64
}
func (s *RangeSamplingStrategy) GetRandomAssetIDs(ctx context.Context, starID string, limit int) ([]int64, error) {
// 1. 获取 ID 范围
minID, maxID, err := repo.GetIDRange(starID)
if err != nil {
return nil, err
}
// 2. 计算桶信息并随机选择桶
bucketSize := s.bucketSize // 默认 10000
totalBuckets := (maxID - minID) / bucketSize + 1
randomBucket := random.Int63n(totalBuckets)
bucketStartID := minID + randomBucket*bucketSize
bucketEndID := bucketStartID + bucketSize
// 3. 在桶内查询 ID 列表
ids, err := repo.GetAssetIDsInRange(starID, bucketStartID, bucketEndID, limit*3)
if err != nil {
return nil, err
}
// 4. 应用层随机打乱
shuffle(ids)
if len(ids) > limit {
ids = ids[:limit]
}
return ids, nil
}
func (s *RangeSamplingStrategy) Name() string {
return "range_sampling"
}
```
**策略选择建议:**
| 数据量 | 方案 | 随机性 | 推荐度 |
|--------|------|--------|--------|
| < 10 | RandomOrderStrategy | 真正随机 | 强烈推荐 |
| > 10 万 | RangeSamplingStrategy | ✅ 跨桶分布均匀 | 强烈推荐 |
**策略切换配置:**
```go
// config.go
// 数据量 < 10 万(推荐)
var randomStrategy RandomStrategy = &RandomOrderStrategy{}
// 数据量 > 10 万(推荐)
// var randomStrategy RandomStrategy = &RangeSamplingStrategy{bucketSize: 10000}
```
> **扩展提示:** 切换策略只需修改配置,将 `randomStrategy` 的具体实现替换,接口和调用方无需改动。
---
## 六、数据模型
### 6.1 Exhibition 表(已有字段)
```go
type Exhibition struct {
ID int64 `gorm:"primaryKey"`
AssetID int64 `gorm:"not null"`
SlotID int64 `gorm:"not null"`
HostProfileID int64 `gorm:"not null"`
OccupierUID int64 `gorm:"not null"`
OccupierStarID int64 `gorm:"not null;index"`
StartTime int64 `gorm:"not null"`
ExpireAt int64 `gorm:"not null;index"`
CreatedAt int64 `gorm:"not null"`
UpdatedAt int64 `gorm:"not null"`
DeletedAt *int64 `gorm:"index"`
}
```
### 6.2 Asset 表关联字段
```go
type Asset struct {
// ... 现有字段 ...
MaterialType string `gorm:"column:material_type"` // 素材类型badge/poster/original
IsOriginal bool `gorm:"column:is_original"`
LikeCount int32 `gorm:"not null;default:0"`
}
```
---
## 七、配置项
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| inspiration_flow_limit | 默认每页数量 | 10 |
| inspiration_flow_max_limit | 最大每页数量 | 20 |
---
## 八、项目文件结构
```
backend/
├── proto/
│ └── gallery.proto # 修改:新增 GetInspirationFlow 方法
├── pkg/proto/
│ ├── gallery/
│ │ ├── gallery.pb.go # 重新生成
│ │ └── gallery.triple.go # 重新生成
├── services/galleryService/
│ ├── repository/
│ │ └── gallery_repository.go # 修改:新增 GetInspirationFlow 方法,使用 ORDER BY RANDOM()
│ │
│ ├── service/
│ │ └── gallery_service.go # 修改:新增 GetInspirationFlow 方法
│ │
│ ├── provider/
│ │ └── gallery_provider.go # 修改:新增 GetInspirationFlow Handler
│ │
│ └── config/
│ └── gallery_config.go # 修改:新增灵感瀑布配置项
└── gateway/
├── controller/
│ └── gallery_controller.go # 修改:新增 GetInspirationFlow 路由处理
├── dto/
│ ├── gallery_dto.go # 修改:新增 InspirationFlow DTO
│ └── gallery_converter.go # 修改:新增转换函数
└── router/
└── router.go # 修改:新增 /api/v1/inspiration-flow 路由
```
---
## 九、数据库变更(必须执行)
**注意:** 当前 `assets` 表**没有** `material_type` 字段,此变更**必须执行**后才能支持按类型过滤。
### 9.1 DDL
```sql
-- assets 表新增 material_type 字段
ALTER TABLE assets ADD COLUMN IF NOT EXISTS material_type VARCHAR(50) DEFAULT 'original';
-- 创建索引优化查询
CREATE INDEX IF NOT EXISTS idx_assets_material_type ON assets(material_type);
```
### 9.2 迁移脚本
```sql
-- backend/scripts/migrate_add_material_type.sql
ALTER TABLE assets ADD COLUMN IF NOT EXISTS material_type VARCHAR(50) DEFAULT 'original';
CREATE INDEX IF NOT EXISTS idx_assets_material_type ON assets(material_type);
```
---
## 十、前端对接说明
### 10.1 首次请求
```
GET /api/v1/inspiration-flow?limit=10&type=all
```
### 10.2 向右滚动(加载新数据)
```
GET /api/v1/inspiration-flow?cursor=eyJsaW1pdCI6MTB9&limit=10&type=all&direction=right
```
### 10.3 向左滚动(加载历史)
```
GET /api/v1/inspiration-flow?cursor=eyJsaW1pdCI6MTB9&limit=10&type=all&direction=left
```
### 10.4 前端逻辑
1. 首次请求 cursor 为空(`?cursor=` 不传或传空direction 默认为 right
2. 解析响应中的 cursor 和 has_more
3. 滚动到右侧边缘时,若 has_more=true携带 cursor 发起下一页请求direction=right
4. 滚动到左侧边缘时,携带 cursor 发起上一页请求direction=left
5. 会话级缓存,刷新后数据重新随机
### 10.5 双向滚动行为说明
- **首次进入**:看到随机顺序的藏品
- **向右滚动**:加载更多新数据(发现新内容)
- **向左滚动**:加载之前看过的历史数据(回看)
- **刷新页面**:数据重新随机,已浏览历史清空
- **首次进入时左滑**:前端禁用左滑操作(因为没有历史数据)
**前端实现要点:**
```javascript
// 判断是否可以左滑
const canScrollLeft = displayedIDs.length > 0;
// 首次进入时禁用左滑
if (!canScrollLeft) {
// 禁用左滑手势
}
// 加载数据后启用左滑
onDataLoaded() {
this.canScrollLeft = true;
}
```
### 10.6 随机展示行为说明
- **每次进入页面**:看到的是新的随机顺序
- **滚动加载过程中**:顺序可能跳变(因为每次都是独立随机)
- **刷新页面**:随机顺序重新生成,已浏览历史清空
---
## 十一、待确认事项
1. **material_type 枚举值**badge/poster/original后续按需扩展
2. **每页数量上限**:当前设为 20移动端优化是否合适
3. **是否需要缓存**:热门 star_id 的数据可以考虑 Redis 缓存
---
## 十二、变更记录
| 日期 | 变更内容 |
|------|---------|
| 2026-04-29 | 重构随机查询逻辑,明确使用 ORDER BY RANDOM() 实现随机展示;添加大数据量扩展说明 |
| 2026-04-29 | 针对移动端优化:默认每页数量从 20 调整为 10最大每页数量从 50 调整为 20 |
| 2026-04-29 | 补充大数据量优化方案:增加"区间采样"方案及 RandomStrategy 扩展接口设计 |
| 2026-04-29 | 移除方案一(随机起点),只保留 ORDER BY RANDOM() 和区间采样两个方案 |
| 2026-04-29 | 明确使用 PostgreSQL 数据库ORDER BY RANDOM() 语法与 MySQL 相同 |
| 2026-04-29 | 改用随机 offset 方案(方案 B每次请求都是独立随机数据变化无影响 |
| 2026-04-29 | 新增双向滚动支持:向右加载新数据,向左加载历史数据,后端维护会话级缓存 |
| 2026-04-29 | 修复:修正重复的 10.4 章节、RangeSamplingStrategy 签名、游标结构说明 |