diff --git a/docs/superpowers/specs/2026-04-27-inspiration-flow-design.md b/docs/superpowers/specs/2026-04-27-inspiration-flow-design.md new file mode 100644 index 0000000..247ca45 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-inspiration-flow-design.md @@ -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。 + +**游标结构(JSON,base64 编码):** +```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"` - 向左滚动,加载历史数据(回看) + +**游标结构(JSON,base64 编码):** +```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. 随机生成 offset(0 ~ 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 签名、游标结构说明 | diff --git a/docs/superpowers/specs/2026-04-27-my-assets-design.md b/docs/superpowers/specs/2026-04-27-my-assets-design.md new file mode 100644 index 0000000..043bd92 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-my-assets-design.md @@ -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 已定义,但代码实现待后续 \ No newline at end of file diff --git a/frontend/manifest.json b/frontend/manifest.json index 59962d9..fadfd6c 100644 --- a/frontend/manifest.json +++ b/frontend/manifest.json @@ -2,7 +2,7 @@ "name" : "TopFans", "appid" : "__UNI__F199FF4", "description" : "", - "versionName" : "1.0.0", + "versionName" : "1.0.1", "versionCode" : "100", "transformPx" : false, /* 5+App特有相关 */ diff --git a/frontend/utils/api.js b/frontend/utils/api.js index 04e3265..d559a05 100644 --- a/frontend/utils/api.js +++ b/frontend/utils/api.js @@ -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) diff --git a/squearbj.png b/squearbj.png new file mode 100644 index 0000000..b40a286 Binary files /dev/null and b/squearbj.png differ