# 查询藏品点赞用户列表接口设计 ## 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`) - 如果缓存每一页的 cursor,key 会很多且难以管理 - 更好的方案:**缓存完整列表,按 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. 未命中 → 查 DB,LIMIT 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); ```