topfans/docs/superpowers/specs/2026-05-21-asset-likers-design.md

398 lines
13 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.

# 查询藏品点赞用户列表接口设计
## 1. 需求概述
**接口名称**: `GET /api/v1/social/assets/:asset_id/likers`
**功能描述**: 查询指定藏品的所有点赞用户列表
**权限控制**: 仅登录用户可访问
**返回内容**: 点赞用户的基本信息(昵称、头像、粉丝等级、点赞时间)
---
## 2. 现有代码分析
### 2.1 相关数据模型
**AssetLike 模型** (`pkg/models/asset.go`):
```go
type AssetLike struct {
ID int64 // 点赞记录ID
AssetID int64 // 资产ID
UserID int64 // 用户ID
StarID int64 // 明星ID
ExhibitionID int64 // 展览ID
CreatedAt int64 // 点赞时间
}
```
**关联关系**:
- `AssetLike.Asset` -> Asset
- `AssetLike.User` -> User
- `AssetLike.Star` -> Star
### 2.2 现有 Repository 层
`asset_like_repository.go` 已有的方法:
- `GetByAsset(assetID, limit, offset)` - 分页获取点赞记录
- `CountByAsset(assetID)` - 统计点赞数
**问题**: 没有 JOIN 用户表,需要新增或扩展方法
**注意**: `GetByAsset` 方法只需要 `assetID`,但 JOIN fan_profiles 需要 `star_id`。由于 `AssetLike` 模型包含 `StarID` 字段,查询时应以 `AssetLike.StarID` 作为 JOIN 条件传给 fan_profiles。
### 2.3 现有 Service 层
`asset_like_service.go` 已有的方法:
- `GetAssetLikes(ctx, assetID, page, pageSize)` - 分页获取点赞列表
**问题**: 只返回 AssetLike 记录,没有返回用户信息
### 2.4 现有 Gateway 层
`social_controller.go` 已有点赞相关端点:
- `POST /api/v1/social/assets/:asset_id/like` - 点赞
- `DELETE /api/v1/social/assets/:asset_id/like` - 取消点赞
**缺失**: 没有查询点赞用户的端点
### 2.5 Proto 定义 [待确认]
需要检查 `pkg/proto/social/` 是否有现成的消息定义。如已有可复用消息(如 `AssetLiker`),直接引用;如无则按 Section 5 新增。
---
## 3. 架构设计
### 3.1 整体架构
```
HTTP Request
Gateway (social_controller.go)
│ GET /api/v1/social/assets/:asset_id/likers
Dubbo RPC Call
SocialService (Dubbo Provider)
├──▶ Redis Cache (asset_likers:{asset_id})
└──▶ SocialRepository ──► asset_likes + users + fan_profiles JOIN
```
### 3.2 数据流
1. Gateway 接收请求,验证登录态
2. 通过 Dubbo 调用 SocialService
3. SocialService 先查 Redis 缓存
4. 缓存未命中 → 调用 SocialRepository 查询(直接访问 DB
5. 组装用户信息返回,写入 Redis 缓存
---
## 4. 性能优化设计
### 4.1 分页设计(必须)
**游标分页 vs OFFSET 分页**
| 方案 | 优点 | 缺点 | 适用场景 |
|------|------|------|----------|
| OFFSET | 简单,支持跳页 | 大 OFFSET 性能差 | 数据量小(<1万 |
| 游标 | 大数据量性能稳定 | 不能跳页 | 数据量大>1万 |
**决策****采用游标分页**
- 理由:藏品点赞列表通常不会很大,但游标分页更健壮
- 实现:`cursor` =上一页最后一条的 `created_at`
- 首次请求 `cursor=0` 时,查 DB 不带游标条件,返回第一页
- `cursor=0` **仅表示不需要 `created_at < ?` 条件**,不表示要重新查 DB
### 4.2 索引优化
**已有索引**
```sql
-- asset_likes 表
CREATE INDEX idx_asset_likes_asset ON asset_likes(asset_id); -- 仅 asset_id
CREATE INDEX idx_asset_likes_user_star ON asset_likes(user_id, star_id); -- 覆盖用户+明星查询
```
**缺失索引(必须新增)**
```sql
-- 游标分页查询WHERE asset_id = ? AND created_at < ? ORDER BY created_at DESC
-- 注意asset_likes 表无软删除字段,无需添加 WHERE 条件
CREATE INDEX idx_asset_likes_asset_created ON asset_likes(asset_id, created_at DESC);
-- 幂等执行:迁移脚本应检查索引是否已存在,避免重复执行报错
```
**JOIN 查询计划**
```
asset_likes ──(idx_asset_likes_asset_created)──▶ 扫描 asset_id + created_at 排序
└──▶ users ──(主键 id)──▶ avatar_url
└──▶ fan_profiles ──(uk_fan_profiles_user_star)──▶ nickname, level
```
**注意**:昵称 nickname 来自 fan_profiles 表,不是 users 表
### 4.3 Redis 缓存策略(必须)
#### 缓存 Key 设计
```
asset_likers:{asset_id} → 完整点赞用户列表JSON
```
**为什么不按 cursor 分页存储**
- 游标分页的 cursor 值是动态的(最后一条的 `liked_at`
- 如果缓存每一页的 cursorkey 会很多且难以管理
- 更好的方案:**缓存完整列表,按 cursor 在内存中切片**
#### 缓存数据结构
```go
// AssetLikersCache 缓存数据结构
type AssetLikersCache struct {
Users []AssetLikerWithTotal `json:"users"` // 完整用户列表(按 liked_at DESC
Total int64 `json:"total"` // 总数
UpdatedAt int64 `json:"updated_at"` // 缓存更新时间
}
// AssetLikerWithTotal 用户+点赞时间
type AssetLikerWithTotal struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
FanLevel int32 `json:"fan_level"`
LikedAt int64 `json:"liked_at"`
StarID int64 `json:"star_id"` // 用于 JOIN fan_profiles缓存时保留
}
```
#### 查询流程
```
1. GET asset_likers:{asset_id}
2. 命中 → 从 users 列表中找 liked_at < cursor 的前 page_size 条cursor=0 时直接取前 page_size 条)
3. 未命中 → 查 DBLIMIT 1000最多缓存1000条写入 Redis
4. 返回数据users, total, has_more, next_cursor
```
**游标=0 的处理逻辑(缓存命中时)**
- 缓存命中时,无论 cursor 是否为 0直接从缓存的完整列表中按 cursor 切片
- `cursor=0` 时返回第一页liked_at 最大的前 page_size 条)
- 缓存未命中时,`cursor=0` 才查 DB且不带游标条件
#### 缓存失效
点赞/取消点赞时删除缓存(应在 `LikeAsset``UnlikeAsset` 方法末尾调用):
```go
// InvalidateAssetLikersCache 删除藏品点赞用户列表缓存
func InvalidateAssetLikersCache(ctx context.Context, assetID int64) error {
if database.RedisClient == nil {
return nil // Redis 未初始化时跳过
}
key := fmt.Sprintf("asset_likers:%d", assetID)
return database.RedisClient.Del(ctx, key).Err()
}
```
#### TTL 设计
- TTL = **60 秒**1分钟
- 理由:藏品点赞延迟 1 分钟可接受,且能有效抗住热点压力
#### 容量规划
| 藏品热度 | 点赞数 | 缓存大小(估算) |
|---------|--------|-----------------|
| 普通 | 0-500 | ~50KB |
| 热门 | 500-5000 | ~500KB |
| 顶流 | 5000+ | ~5MB截断到1000条|
**截断策略**:最多缓存 1000 条,超出部分不缓存(直接查 DB
**截断边界行为**
- 当某藏品点赞数 > 1000 时,缓存只存储前 1000 条
- 用户翻页 cursor 超出第 1000 条时 → 缓存未命中 → 直接查 DB不写入缓存
- 第 1000 条以内的翻页请求均命中缓存,保证热点藏品翻页稳定
**缓存雪崩防护**
- TTL = 60 秒,保证缓存会自然过期
- 即使热点藏品频繁更新,最坏情况延迟也只有 60 秒
#### 需要修改的文件
1. `backend/pkg/database/redis.go` - 添加 `GetAssetLikersCache``SetAssetLikersCache` 方法
2. `backend/services/socialService/service/asset_like_service.go` - `GetAssetLikers` 方法:查缓存 → 缓存未命中查DB → 写入缓存
3. `backend/services/socialService/service/asset_like_service.go` - `LikeAsset` / `UnlikeAsset` 方法末尾调用 `InvalidateAssetLikersCache`
---
## 5. Proto 定义 (pkg/proto/social/)
**新增请求消息**:
```protobuf
message GetAssetLikersRequest {
int64 asset_id = 1;
int32 page_size = 2; // 每页数量默认20最大100
int64 cursor = 3; // 游标(上一页最后一条的 created_at首次请求传0
}
message GetAssetLikersResponse {
CommonPB base = 1;
repeated AssetLiker users = 2;
int64 total = 3;
bool has_more = 4; // 是否有更多
int64 next_cursor = 5; // 下一页游标
}
message AssetLiker {
int64 user_id = 1;
string nickname = 2;
string avatar = 3;
int32 fan_level = 4;
int64 liked_at = 5;
}
```
### 5.1 Repository 层
```go
// GetByAssetWithUsers 获取资产的点赞用户列表(带用户信息,游标分页)
// 注意JOIN fan_profiles 需要 star_id查询时从 AssetLike.StarID 获取
GetByAssetWithUsers(assetID int64, starID int64, cursor int64, limit int) ([]*AssetLikeWithUser, error)
// AssetLikeWithUser 点赞记录+用户信息
type AssetLikeWithUser struct {
UserID int64
Nickname string // 来自 fan_profiles.nickname
Avatar string // 来自 users.avatar_url
FanLevel int32 // 来自 fan_profiles.level
LikedAt int64
StarID int64 // 来自 al.star_id用于 JOIN fan_profiles
}
```
**SQL 查询(游标分页)**:
```sql
SELECT
al.user_id,
al.star_id, -- 用于后续 fan_profiles JOIN需显式 select
COALESCE(fp.nickname, '匿名用户') as nickname, -- 昵称来自 fan_profiles
u.avatar_url, -- 头像来自 users
COALESCE(fp.level, 1) as fan_level, -- 粉丝等级别名,统一输出为 fan_level
al.created_at as liked_at
FROM asset_likes al
JOIN users u ON al.user_id = u.id
LEFT JOIN fan_profiles fp ON al.user_id = fp.user_id AND al.star_id = fp.star_id -- star_id 来自 al.star_id
WHERE al.asset_id = ?
AND (? = 0 OR al.created_at < ?) -- cursor=0 时跳过游标条件,返回第一页
ORDER BY al.created_at DESC
LIMIT ? -- page_size
```
**注意**
- `users` 表无 nickname 字段,昵称来自 `fan_profiles.nickname`
- 需要 `COALESCE` 处理用户可能没有 fan_profile 的情况
### 5.2 Service 层
```go
func (s *AssetLikeService) GetAssetLikers(ctx context.Context, assetID int64, starID int64, cursor int64, pageSize int32) (*GetAssetLikersResponse, error)
```
**starID 来源**
-`Asset` 模型中获取Asset 与 Star 有关联)
- 或从请求上下文/缓存中获取(待实现时确认)
**校验逻辑**
1. 验证资产是否存在(调用 `assetClient.GetAssetForRPC`
2. 资产不存在返回错误
3. 查询缓存 → 命中则直接切片返回
4. 未命中 → 查 DB传入 starID 用于 JOIN fan_profiles→ 写入缓存 → 返回
### 5.3 Gateway 层
**路由**: `GET /api/v1/social/assets/:asset_id/likers`
**请求参数**:
- `asset_id`: 路径参数
- `page_size`: 查询参数默认20最大100
- `cursor`: 查询参数(上一页返回的 next_cursor首次请求传0
**响应格式**:
```json
{
"code": 0,
"message": "success",
"data": {
"users": [
{
"user_id": 123,
"nickname": "用户昵称", // 来自 fan_profiles.nickname
"avatar": "https://...", // 来自 users.avatar_url
"fan_level": 5, // 来自 fan_profiles.level
"liked_at": 1716200000000
}
],
"total": 100,
"has_more": true,
"next_cursor": 1716199999000
}
}
```
---
## 6. 路由注册
`router.go` 中添加:
```go
social.GET("/assets/:asset_id/likers", socialCtrl.GetAssetLikers)
```
---
## 7. 错误处理 [待确认具体错误码]
| 错误场景 | HTTP 状态码 | 错误码 | 错误信息 |
|---------|------------|--------|---------|
| 未登录 | 401 | `UNAUTHORIZED` | 未授权 |
| 资产不存在 | 404 | `ASSET_NOT_FOUND` | 资产不存在 |
| 服务调用失败 | 500 | `INTERNAL_ERROR` | 服务调用失败 |
---
## 8. 文件清单
需要修改的文件:
1. `backend/scripts/migrate_asset_likes_cursor_pagination.sql` - 新增 `idx_asset_likes_asset_created` 索引
2. `backend/proto/social.proto` - 新增 GetAssetLikersRequest/GetAssetLikersResponse/AssetLiker 消息
3. `backend/pkg/proto/social/*.go` - 重新生成(需执行 protoc 编译)
4. `backend/pkg/database/redis.go` - 新增 GetAssetLikersCache / SetAssetLikersCache / InvalidateAssetLikersCache 方法
5. `backend/services/socialService/repository/social_repository.go` - 新增 `GetByAssetWithUsers(assetID, starID, cursor, limit)` 接口方法和实现
6. `backend/services/socialService/service/asset_like_service.go` - 新增 GetAssetLikers 方法(含缓存逻辑),修改 LikeAsset/UnlikeAsset 添加缓存失效
7. `backend/services/socialService/provider/social_provider.go` - 在 SocialServiceHandler 接口添加 GetAssetLikers 方法注册
8. `backend/gateway/controller/social_controller.go` - 新增 GetAssetLikers Handler
9. `backend/gateway/router/router.go` - 注册路由 `GET /assets/:asset_id/likers`
---
## 9. 实现顺序
1. 新增索引(迁移脚本)
2. Proto 定义(消息结构)
3. Redis 缓存方法(`backend/pkg/database/redis.go`
4. Repository 层(数据查询)
5. **点赞/取消点赞时触发缓存失效**(在 Service 层修改前先加,确保无竞态)
6. Service 层(业务逻辑 + 缓存读取)
7. Provider 层(注册 RPC 方法)
8. Controller 层HTTP 处理)
9. 路由注册
---
## 10. 数据库迁移
```sql
-- 为游标分页新增索引(幂等)
CREATE INDEX IF NOT EXISTS idx_asset_likes_asset_created ON asset_likes(asset_id, created_at DESC);
```