package cp import ( "context" "encoding/json" "fmt" "strings" "github.com/redis/go-redis/v9" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/xerr" cpdomain "hyapp/services/user-service/internal/domain/cp" ) const ( defaultIntimacyLeaderboardKeyPrefix = "user:cp_intimacy_leaderboard" intimacyLeaderboardRelationAll = "all" ) // RedisIntimacyLeaderboardStore 使用 zset 保存 CP 亲密值排行榜;score 以 intimacy_value 为主排序并写入批次内 tie-break,member 是双方展示资料 JSON。 type RedisIntimacyLeaderboardStore struct { client *redis.Client keyPrefix string } // NewRedisIntimacyLeaderboardClient 创建 Redis 客户端并探测连通性,避免 cron 首次执行才发现配置错误。 func NewRedisIntimacyLeaderboardClient(ctx context.Context, addr string, password string, db int) (*redis.Client, error) { client := redis.NewClient(&redis.Options{ Addr: strings.TrimSpace(addr), Password: password, DB: db, }) // 榜单 Redis 是 App 查询链路的唯一读模型,不按登录风控那样 fail-open;启动期探测失败直接暴露配置问题。 if err := client.Ping(ctx).Err(); err != nil { _ = client.Close() return nil, err } return client, nil } // NewRedisIntimacyLeaderboardStore 基于共享 Redis client 创建亲密榜读模型。 func NewRedisIntimacyLeaderboardStore(client *redis.Client, keyPrefix string) *RedisIntimacyLeaderboardStore { keyPrefix = strings.Trim(strings.TrimSpace(keyPrefix), ":") if keyPrefix == "" { // keyPrefix 统一去掉首尾冒号,最终 key 固定为 prefix:app:relation,避免配置里多写冒号产生双冒号 key。 keyPrefix = defaultIntimacyLeaderboardKeyPrefix } return &RedisIntimacyLeaderboardStore{client: client, keyPrefix: keyPrefix} } // ReplaceIntimacyLeaderboard 每轮全量替换 all/cp/brother/sister 四个 zset,保证资料快照和关系状态可被重建覆盖。 func (s *RedisIntimacyLeaderboardStore) ReplaceIntimacyLeaderboard(ctx context.Context, app string, entries []cpdomain.IntimacyLeaderboardEntry) error { if s == nil || s.client == nil { return xerr.New(xerr.Unavailable, "cp intimacy leaderboard redis is not configured") } grouped := map[string][]redis.Z{ intimacyLeaderboardRelationAll: {}, cpdomain.RelationTypeCP: {}, cpdomain.RelationTypeBrother: {}, cpdomain.RelationTypeSister: {}, } for index, entry := range entries { relationType := normalizeRelationType(entry.RelationType) if relationType == "" || entry.RelationshipID == "" { // 异常关系类型或空 relationship_id 不写入榜单,避免 Redis member 变成无法回溯的脏读模型。 continue } member, err := encodeIntimacyLeaderboardMember(entry) if err != nil { return err } // Redis zset 在同 score 时会按 member 字符串排序;MySQL 已经按更新时间和关系 ID 给出稳定顺序, // 这里把批次内顺序编码进小偏移,保证亲密值相同的关系不会因为 JSON 字符串变化而跳名次。 z := redis.Z{Score: intimacyLeaderboardScore(entry.IntimacyValue, index, len(entries)), Member: member} // all 榜给 App 默认入口使用,按关系类型拆出的榜给筛选页使用;四个 key 同轮替换,保证视图时间一致。 grouped[intimacyLeaderboardRelationAll] = append(grouped[intimacyLeaderboardRelationAll], z) grouped[relationType] = append(grouped[relationType], z) } for relationType, members := range grouped { // 即使某个关系类型本轮没有数据也要执行 replaceKey,让已结束关系从旧榜单里被清掉。 if err := s.replaceKey(ctx, s.key(app, relationType), members); err != nil { return err } } return nil } // ListIntimacyLeaderboard 读取 zset 分页;rank 由当前 zset 顺序计算,不信任 member 里的旧 rank。 func (s *RedisIntimacyLeaderboardStore) ListIntimacyLeaderboard(ctx context.Context, app string, relationType string, page int32, pageSize int32, nowMs int64) (cpdomain.IntimacyLeaderboardPage, error) { if s == nil || s.client == nil { return cpdomain.IntimacyLeaderboardPage{}, xerr.New(xerr.Unavailable, "cp intimacy leaderboard redis is not configured") } relationType = normalizeRelationType(relationType) if relationType == "" { relationType = intimacyLeaderboardRelationAll } page, pageSize = normalizeLeaderboardPage(page, pageSize) key := s.key(app, relationType) // zset 是降序榜单,offset/stop 按客户端分页直接映射;pageSize 已在 service 内限制最大值。 offset := int64((page - 1) * pageSize) stop := offset + int64(pageSize) - 1 // total 用 ZCARD 取当前 key 的真实长度;空 key 返回 0,不需要额外区分“还没刷新”和“当前无数据”。 total, err := s.client.ZCard(ctx, key).Result() if err != nil { return cpdomain.IntimacyLeaderboardPage{}, err } rows, err := s.client.ZRevRangeWithScores(ctx, key, offset, stop).Result() if err != nil { return cpdomain.IntimacyLeaderboardPage{}, err } items := make([]cpdomain.IntimacyLeaderboardEntry, 0, len(rows)) for index, row := range rows { member, ok := row.Member.(string) if !ok || strings.TrimSpace(member) == "" { continue } var entry cpdomain.IntimacyLeaderboardEntry if err := json.Unmarshal([]byte(member), &entry); err != nil { return cpdomain.IntimacyLeaderboardPage{}, err } // rank 只由当前 Redis 顺序计算,member 内写入时会清零,避免历史 rank 在重新排序后污染返回值。 entry.Rank = offset + int64(index) + 1 items = append(items, entry) } return cpdomain.IntimacyLeaderboardPage{ Items: items, Total: total, Page: page, PageSize: pageSize, ServerTimeMS: nowMs, }, nil } func (s *RedisIntimacyLeaderboardStore) replaceKey(ctx context.Context, key string, members []redis.Z) error { if len(members) == 0 { // 全量重建时空集合代表该榜单当前没有 active 关系,直接删除旧 key,避免 App 看到过期关系。 return s.client.Del(ctx, key).Err() } tmpKey := key + ":tmp:" + idgen.New("cprank") pipe := s.client.Pipeline() // 先写临时 key,再 RENAME 覆盖正式 key;读取方要么看到旧榜,要么看到新榜,不会看到半截榜单。 pipe.Del(ctx, tmpKey) pipe.ZAdd(ctx, tmpKey, members...) pipe.Rename(ctx, tmpKey, key) _, err := pipe.Exec(ctx) return err } func (s *RedisIntimacyLeaderboardStore) key(app string, relationType string) string { app = appcode.Normalize(app) relationType = strings.TrimSpace(relationType) if relationType == "" { relationType = intimacyLeaderboardRelationAll } return fmt.Sprintf("%s:%s:%s", s.keyPrefix, app, relationType) } func encodeIntimacyLeaderboardMember(entry cpdomain.IntimacyLeaderboardEntry) (string, error) { // rank 是读取时按 zset 顺序计算的派生值,写入 member 前清零,避免同一关系在不同分榜里带着错误名次。 entry.Rank = 0 payload, err := json.Marshal(entry) if err != nil { return "", err } return string(payload), nil } func intimacyLeaderboardScore(intimacyValue int64, index int, total int) float64 { if total <= 0 { total = 1 } // score = 亲密值 * (total+1) + 批次倒序偏移;这样任意 1 点亲密值差距都大于所有 tie-break 偏移。 tieOffset := total - index if tieOffset < 0 { tieOffset = 0 } return float64(intimacyValue)*float64(total+1) + float64(tieOffset) }