Merge branch 'dev' into feature/waterfall-square

This commit is contained in:
zerosaturation 2026-04-29 18:06:07 +08:00
commit 4f77f8c68c
5 changed files with 1481 additions and 3 deletions

View File

@ -0,0 +1,697 @@
# 灵感瀑布流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 签名、游标结构说明 |

View File

@ -0,0 +1,781 @@
# 我的作品统计(点赞/展出)设计文档
> **创建日期:** 2026-04-27
> **项目:** TopFans 我的作品统计
> **服务:** socialService / galleryService
> **状态:** 设计中
---
## 一、设计目标
提供用户查看自己点赞过的作品和展出过的作品的统计接口,返回实时点赞数。
---
## 二、数据来源
### 2.1 我点赞的作品
**主表:** asset_likes点赞记录表
**关联表:**
- assets资产表- 用于获取藏品信息
- exhibitions展品展示表- 用于过滤展出中且未过期的藏品
- exhibition_revenue_records收益记录表- 用于获取当前可领取收益
**筛选条件:**
- `user_id = ?` (当前用户)
- `star_id = ?` (当前 star_id)
- `assets.deleted_at IS NULL` (藏品未删除)
- `assets.is_active = true` (藏品已激活)
- `exhibitions.deleted_at IS NULL` (展出记录未删除)
- `exhibitions.expire_at > now` (展出未过期)
### 2.2 我展出的作品
**主表:** exhibitions展品展示表
**关联表:**
- assets资产表- 用于获取藏品信息
- exhibition_revenue_records收益记录表- 用于获取当前可领取收益
**筛选条件:**
- `occupier_uid = ?` (当前用户)
- `occupier_star_id = ?` (当前 star_id)
- `deleted_at IS NULL` (未删除)
---
## 三、API 设计
### 3.1 获取我点赞的作品列表
```
GET /api/v1/me/liked-assets
```
**Query 参数:**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| page | int | 否 | 1 | 页码 |
| page_size | int | 否 | 20 | 每页数量最大100 |
**HTTP 响应:**
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [
{
"asset_id": 123,
"name": "藏品名称",
"cover_url": "https://xxx.com/cover.png",
"like_count": 100,
"liked_at": 1714214400000
}
],
"page": 1,
"page_size": 20,
"total": 50,
"has_more": true
}
}
```
**字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| asset_id | int64 | 资产ID |
| name | string | 藏品名称 |
| cover_url | string | 封面图URL |
| like_count | int32 | 实时点赞数(来自 assets 表) |
| liked_at | int64 | 用户点赞该作品的时间(毫秒时间戳) |
| earnings | int64 | 当前可领取收益status='claimable' 的 crystal_amount 汇总) |
---
### 3.2 获取我展出的作品列表
```
GET /api/v1/me/exhibited-assets
```
**Query 参数:**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| page | int | 否 | 1 | 页码 |
| page_size | int | 否 | 20 | 每页数量最大100 |
**HTTP 响应:**
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [
{
"asset_id": 123,
"name": "藏品名称",
"cover_url": "https://xxx.com/cover.png",
"like_count": 100,
"exhibited_at": 1714214400000,
"expire_at": 1714278400000,
"earnings": 500
}
],
"page": 1,
"page_size": 20,
"total": 10,
"has_more": false
}
}
```
**字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| asset_id | int64 | 资产ID |
| name | string | 藏品名称 |
| cover_url | string | 封面图URL |
| like_count | int32 | 实时点赞数(来自 assets 表) |
| exhibited_at | int64 | 展出开始时间(毫秒时间戳) |
| expire_at | int64 | 展出过期时间(毫秒时间戳) |
| earnings | int64 | 当前可领取收益status='claimable' 的 crystal_amount 汇总) |
---
### 3.3 获取我今日点赞的作品(暂不实现)
```
GET /api/v1/me/today-liked-assets
```
**Query 参数:**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| page | int | 否 | 1 | 页码 |
| page_size | int | 否 | 20 | 每页数量最大100 |
**HTTP 响应:**
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [
{
"asset_id": 123,
"name": "藏品名称",
"cover_url": "https://xxx.com/cover.png",
"like_count": 100,
"liked_at": 1714214400000
}
],
"page": 1,
"page_size": 20,
"total": 50,
"has_more": true
}
}
```
**字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| asset_id | int64 | 资产ID |
| name | string | 藏品名称 |
| cover_url | string | 封面图URL |
| like_count | int32 | 实时点赞数(来自 assets 表) |
| liked_at | int64 | 用户点赞该作品的时间(毫秒时间戳) |
> **状态:暂不实现**
---
### 3.4 获取我本周点赞的作品(暂不实现)
```
GET /api/v1/me/week-liked-assets
```
**Query 参数:**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| page | int | 否 | 1 | 页码 |
| page_size | int | 否 | 20 | 每页数量最大100 |
**HTTP 响应:**
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [
{
"asset_id": 123,
"name": "藏品名称",
"cover_url": "https://xxx.com/cover.png",
"like_count": 100,
"liked_at": 1714214400000
}
],
"page": 1,
"page_size": 20,
"total": 50,
"has_more": true
}
}
```
**字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| asset_id | int64 | 资产ID |
| name | string | 藏品名称 |
| cover_url | string | 封面图URL |
| like_count | int32 | 实时点赞数(来自 assets 表) |
| liked_at | int64 | 用户点赞该作品的时间(毫秒时间戳) |
> **状态:暂不实现**
---
### 3.5 获取他人点赞的作品列表(暂不实现)
```
GET /api/v1/users/{user_id}/liked-assets
```
**Path 参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| user_id | int64 | 是 | 他人用户ID |
**Query 参数:**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| page | int | 否 | 1 | 页码 |
| page_size | int | 否 | 20 | 每页数量最大100 |
**HTTP 响应:**
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [
{
"asset_id": 123,
"name": "藏品名称",
"cover_url": "https://xxx.com/cover.png",
"like_count": 100,
"liked_at": 1714214400000
}
],
"page": 1,
"page_size": 20,
"total": 50,
"has_more": true
}
}
```
**字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| asset_id | int64 | 资产ID |
| name | string | 藏品名称 |
| cover_url | string | 封面图URL |
| like_count | int32 | 实时点赞数(来自 assets 表) |
| liked_at | int64 | 用户点赞该作品的时间(毫秒时间戳) |
> **状态:暂不实现**
---
### 3.6 获取他人展出的作品列表(暂不实现)
```
GET /api/v1/users/{user_id}/exhibited-assets
```
**Path 参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| user_id | int64 | 是 | 他人用户ID |
**Query 参数:**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| page | int | 否 | 1 | 页码 |
| page_size | int | 否 | 20 | 每页数量最大100 |
**HTTP 响应:**
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [
{
"asset_id": 123,
"name": "藏品名称",
"cover_url": "https://xxx.com/cover.png",
"like_count": 100,
"exhibited_at": 1714214400000,
"expire_at": 1714278400000
}
],
"page": 1,
"page_size": 20,
"total": 10,
"has_more": false
}
}
```
**字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| asset_id | int64 | 资产ID |
| name | string | 藏品名称 |
| cover_url | string | 封面图URL |
| like_count | int32 | 实时点赞数(来自 assets 表) |
| exhibited_at | int64 | 展出开始时间(毫秒时间戳) |
| expire_at | int64 | 展出过期时间(毫秒时间戳) |
> **状态:暂不实现**
---
### 3.7 错误码
| code | 说明 |
|------|------|
| 200 | 成功 |
| 401 | 用户认证失败 |
| 500 | 服务器内部错误 |
---
## 四、Proto 定义
### 4.1 我点赞的作品
```protobuf
// 获取我点赞的作品列表请求
message GetMyLikedAssetsRequest {
int32 page = 1; // 页码默认1
int32 page_size = 2; // 每页数量默认20最大100
}
// 获取我点赞的作品列表响应
message GetMyLikedAssetsResponse {
topfans.common.BaseResponse base = 1;
LikedAssetsData data = 2;
}
// 点赞作品数据
message LikedAssetsData {
repeated LikedAssetItem items = 1; // 作品列表
int32 page = 2; // 当前页码
int32 page_size = 3; // 每页数量
int64 total = 4; // 总数量
bool has_more = 5; // 是否有更多
}
// 点赞作品项
message LikedAssetItem {
int64 asset_id = 1; // 资产ID
string name = 2; // 藏品名称
string cover_url = 3; // 封面图URL
int32 like_count = 4; // 实时点赞数
int64 liked_at = 5; // 点赞时间(毫秒时间戳)
int64 earnings = 6; // 当前可领取收益
}
```
---
### 4.2 我展出的作品
```protobuf
// 获取我展出的作品列表请求
message GetMyExhibitedAssetsRequest {
int32 page = 1; // 页码默认1
int32 page_size = 2; // 每页数量默认20最大100
}
// 获取我展出的作品列表响应
message GetMyExhibitedAssetsResponse {
topfans.common.BaseResponse base = 1;
ExhibitedAssetsData data = 2;
}
// 展出作品数据
message ExhibitedAssetsData {
repeated ExhibitedAssetItem items = 1; // 作品列表
int32 page = 2; // 当前页码
int32 page_size = 3; // 每页数量
int64 total = 4; // 总数量
bool has_more = 5; // 是否有更多
}
// 展出作品项
message ExhibitedAssetItem {
int64 asset_id = 1; // 资产ID
string name = 2; // 藏品名称
string cover_url = 3; // 封面图URL
int32 like_count = 4; // 实时点赞数
int64 exhibited_at = 5; // 展出开始时间(毫秒时间戳)
int64 expire_at = 6; // 展出过期时间(毫秒时间戳)
int64 earnings = 7; // 当前可领取收益
}
```
---
### 4.3 我今日/本周点赞的作品(暂不实现)
```protobuf
// 获取我今日点赞的作品列表请求
message GetMyTodayLikedAssetsRequest {
int32 page = 1; // 页码默认1
int32 page_size = 2; // 每页数量默认20最大100
}
// 获取我今日点赞的作品列表响应
message GetMyTodayLikedAssetsResponse {
topfans.common.BaseResponse base = 1;
LikedAssetsData data = 2;
}
// 获取我本周点赞的作品列表请求
message GetMyWeekLikedAssetsRequest {
int32 page = 1; // 页码默认1
int32 page_size = 2; // 每页数量默认20最大100
}
// 获取我本周点赞的作品列表响应
message GetMyWeekLikedAssetsResponse {
topfans.common.BaseResponse base = 1;
LikedAssetsData data = 2;
}
```
> **状态:暂不实现**
---
### 4.4 他人点赞/展出的作品(暂不实现)
```protobuf
// 获取他人点赞的作品列表请求
message GetUserLikedAssetsRequest {
int64 user_id = 1; // 他人用户ID
int32 page = 2; // 页码默认1
int32 page_size = 3; // 每页数量默认20最大100
}
// 获取他人点赞的作品列表响应
message GetUserLikedAssetsResponse {
topfans.common.BaseResponse base = 1;
LikedAssetsData data = 2;
}
// 获取他人展出的作品列表请求
message GetUserExhibitedAssetsRequest {
int64 user_id = 1; // 他人用户ID
int32 page = 2; // 页码默认1
int32 page_size = 3; // 每页数量默认20最大100
}
// 获取他人展出的作品列表响应
message GetUserExhibitedAssetsResponse {
topfans.common.BaseResponse base = 1;
ExhibitedAssetsData data = 2;
}
```
> **状态:暂不实现**
---
### 4.5 Service 方法
在 SocialService 中新增方法:
```protobuf
// 社交服务
service SocialService {
// ... 现有方法 ...
// 获取我点赞的作品列表
rpc GetMyLikedAssets(GetMyLikedAssetsRequest) returns (GetMyLikedAssetsResponse) {
option (google.api.http) = {
get: "/api/v1/me/liked-assets"
};
}
// 获取我今日点赞的作品列表(暂不实现)
rpc GetMyTodayLikedAssets(GetMyTodayLikedAssetsRequest) returns (GetMyTodayLikedAssetsResponse) {
option (google.api.http) = {
get: "/api/v1/me/today-liked-assets"
};
}
// 获取我本周点赞的作品列表(暂不实现)
rpc GetMyWeekLikedAssets(GetMyWeekLikedAssetsRequest) returns (GetMyWeekLikedAssetsResponse) {
option (google.api.http) = {
get: "/api/v1/me/week-liked-assets"
};
}
// 获取他人点赞的作品列表(暂不实现)
rpc GetUserLikedAssets(GetUserLikedAssetsRequest) returns (GetUserLikedAssetsResponse) {
option (google.api.http) = {
get: "/api/v1/users/{user_id}/liked-assets"
};
}
}
```
在 GalleryService 中新增方法:
```protobuf
// 展馆服务
service GalleryService {
// ... 现有方法 ...
// 获取我展出的作品列表
rpc GetMyExhibitedAssets(GetMyExhibitedAssetsRequest) returns (GetMyExhibitedAssetsResponse) {
option (google.api.http) = {
get: "/api/v1/me/exhibited-assets"
};
}
// 获取他人展出的作品列表(暂不实现)
rpc GetUserExhibitedAssets(GetUserExhibitedAssetsRequest) returns (GetUserExhibitedAssetsResponse) {
option (google.api.http) = {
get: "/api/v1/users/{user_id}/exhibited-assets"
};
}
}
```
---
## 五、核心逻辑
### 5.1 查询我点赞的作品(只返回展出中且未过期的)
```sql
SELECT
al.asset_id,
a.name,
a.cover_url,
a.like_count,
al.created_at as liked_at,
COALESCE(SUM(err.crystal_amount), 0) as earnings
FROM asset_likes al
JOIN assets a ON al.asset_id = a.id
JOIN exhibitions e ON e.asset_id = a.id
LEFT JOIN exhibition_revenue_records err ON err.asset_id = a.id AND err.status = 'claimable'
WHERE al.user_id = ?
AND al.star_id = ?
AND a.deleted_at IS NULL
AND a.is_active = true
AND e.deleted_at IS NULL
AND e.expire_at > ?
GROUP BY al.asset_id, a.name, a.cover_url, a.like_count, al.created_at
ORDER BY al.created_at DESC
LIMIT ? OFFSET ?;
-- 计数
SELECT COUNT(DISTINCT al.asset_id)
FROM asset_likes al
JOIN assets a ON al.asset_id = a.id
JOIN exhibitions e ON e.asset_id = a.id
WHERE al.user_id = ?
AND al.star_id = ?
AND a.deleted_at IS NULL
AND a.is_active = true
AND e.deleted_at IS NULL
AND e.expire_at > ?;
```
**参数说明:**
- `? = user_id` (当前用户)
- `? = star_id` (当前 star_id)
- `? = now` (当前时间戳,只显示展出中且未过期的)
- `? = page_size`
- `? = (page - 1) * page_size`
---
### 5.2 查询我展出的作品(只返回展出中的)
```sql
SELECT
e.asset_id,
a.name,
a.cover_url,
a.like_count,
e.start_time as exhibited_at,
e.expire_at,
COALESCE(SUM(err.crystal_amount), 0) as earnings
FROM exhibitions e
JOIN assets a ON e.asset_id = a.id
LEFT JOIN exhibition_revenue_records err ON err.asset_id = a.id AND err.status = 'claimable'
WHERE e.occupier_uid = ?
AND e.occupier_star_id = ?
AND e.deleted_at IS NULL
AND e.expire_at > ? -- 只返回未过期的
GROUP BY e.asset_id, a.name, a.cover_url, a.like_count, e.start_time, e.expire_at
ORDER BY e.start_time DESC
LIMIT ? OFFSET ?;
-- 计数
SELECT COUNT(*)
FROM exhibitions e
WHERE e.occupier_uid = ?
AND e.occupier_star_id = ?
AND e.deleted_at IS NULL
AND e.expire_at > ?; -- 只返回未过期的
```
**参数说明:**
- `? = user_id` (当前用户)
- `? = star_id` (当前 star_id)
- `? = now` (当前时间戳,只显示未过期的)
- `? = page_size`
- `? = (page - 1) * page_size`
---
## 六、数据模型
### 6.1 asset_likes 表(已有字段)
```go
type AssetLike struct {
ID int64 `gorm:"primaryKey"`
AssetID int64 `gorm:"not null;uniqueIndex:uk_asset_likes_user_asset"`
UserID int64 `gorm:"not null;uniqueIndex:uk_asset_likes_user_asset"`
StarID int64 `gorm:"not null;index"`
CreatedAt int64 `gorm:"not null;index"`
}
```
### 6.2 exhibitions 表(已有字段)
```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;index"`
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.3 assets 表(已有字段)
```go
type Asset struct {
ID int64 `gorm:"primaryKey"`
Name string `gorm:"type:varchar(100);not null"`
CoverURL string `gorm:"type:varchar(500);not null"`
LikeCount int32 `gorm:"not null;default:0"`
Status int32 `gorm:"not null;default:0"`
DeletedAt *int64 `gorm:"index"`
IsActive bool `gorm:"default:true;not null"`
}
```
---
## 七、项目文件结构
```
backend/
├── proto/
│ ├── social.proto # 修改:新增 GetMyLikedAssets 方法
│ └── gallery.proto # 修改:新增 GetMyExhibitedAssets 方法
├── pkg/proto/
│ ├── social/
│ │ ├── social.pb.go # 重新生成
│ │ └── social.triple.go # 重新生成
│ └── gallery/
│ ├── gallery.pb.go # 重新生成
│ └── gallery.triple.go # 重新生成
├── services/socialService/
│ ├── repository/
│ │ └── asset_like_repository.go # 修改:新增查询方法
│ │
│ ├── service/
│ │ └── asset_like_service.go # 修改:新增 GetMyLikedAssets 方法
│ │
│ └── provider/
│ └── social_provider.go # 修改:新增 GetMyLikedAssets Handler
├── services/galleryService/
│ ├── repository/
│ │ └── gallery_repository.go # 修改:新增 GetExhibitionsByOccupier 方法
│ │
│ ├── service/
│ │ └── exhibition_service.go # 修改:新增 GetMyExhibitedAssets 方法
│ │
│ └── provider/
│ └── gallery_provider.go # 修改:新增 GetMyExhibitedAssets Handler
└── gateway/
├── controller/
│ ├── social_controller.go # 修改:新增 /api/v1/me/liked-assets 路由
│ └── gallery_controller.go # 修改:新增 /api/v1/me/exhibited-assets 路由
└── router/
└── router.go # 修改:新增路由配置
```
---
## 八、已确认事项
1. **只显示展出中的作品** — 通过 `expire_at > now` 过滤
2. **排序方式** — 按展出时间倒序start_time DESC
3. **分页大小** — 默认 20最大 100
4. **点赞作品也只显示展出中且未过期的** — 通过 JOIN exhibitions 并过滤 `expire_at > now`
5. **今日/本周点赞暂不实现** — API 和 Proto 已定义,但代码实现待后续
6. **每个藏品返回当前可领取收益** — 关联 exhibition_revenue_records 表,汇总 `status='claimable'``crystal_amount`
7. **他人点赞/展出的作品列表暂不实现** — API 和 Proto 已定义,但代码实现待后续

View File

@ -2,7 +2,7 @@
"name" : "TopFans",
"appid" : "__UNI__F199FF4",
"description" : "",
"versionName" : "1.0.0",
"versionName" : "1.0.1",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */

View File

@ -1,6 +1,6 @@
// API 基础配置
// const baseURL = 'http://101.132.250.62:8080'
const baseURL = 'http://192.168.110.60:8080'
const baseURL = 'http://101.132.250.62:8080'
// const baseURL = 'http://192.168.110.60:8080'
// const baseURL = 'http://localhost:8080'
// 是否使用模拟数据(开发调试时设为 true后端API准备好后改为 false

BIN
squearbj.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB