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

13 KiB
Raw Blame History

查询藏品点赞用户列表接口设计

1. 需求概述

接口名称: GET /api/v1/social/assets/:asset_id/likers

功能描述: 查询指定藏品的所有点赞用户列表

权限控制: 仅登录用户可访问

返回内容: 点赞用户的基本信息(昵称、头像、粉丝等级、点赞时间)


2. 现有代码分析

2.1 相关数据模型

AssetLike 模型 (pkg/models/asset.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 索引优化

已有索引

-- 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);       -- 覆盖用户+明星查询

缺失索引(必须新增)

-- 游标分页查询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 在内存中切片

缓存数据结构

// 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且不带游标条件

缓存失效

点赞/取消点赞时删除缓存(应在 LikeAssetUnlikeAsset 方法末尾调用):

// 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 - 添加 GetAssetLikersCacheSetAssetLikersCache 方法
  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/)

新增请求消息:

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 层

// 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 查询(游标分页):

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 层

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

响应格式:

{
    "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 中添加:

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. 数据库迁移

-- 为游标分页新增索引(幂等)
CREATE INDEX IF NOT EXISTS idx_asset_likes_asset_created ON asset_likes(asset_id, created_at DESC);