2026-06-11 13:33:44 +08:00

181 lines
7.3 KiB
Go
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.

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-breakmember 是双方展示资料 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)
}