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