2026-06-16 17:00:06 +08:00

346 lines
15 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 承载 CP/兄弟/姐妹关系的用例编排。
package cp
import (
"context"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
cpdomain "hyapp/services/user-service/internal/domain/cp"
)
const (
defaultIntimacyLeaderboardPageSize = int32(20)
maxIntimacyLeaderboardPageSize = int32(100)
defaultIntimacyLeaderboardRows = 10000
maxIntimacyLeaderboardRows = 10000
)
// Repository 描述 CP 关系 service 需要的持久化能力,便于后续单元测试替换 MySQL 实现。
type Repository interface {
ListApplications(ctx context.Context, userID int64, direction string, status string, page int32, pageSize int32, nowMs int64) ([]cpdomain.Application, int64, error)
AcceptApplication(ctx context.Context, userID int64, applicationID string, nowMs int64) (cpdomain.Application, cpdomain.Relationship, error)
RejectApplication(ctx context.Context, userID int64, applicationID string, reason string, nowMs int64) (cpdomain.Application, error)
ListRelationships(ctx context.Context, userID int64, relationType string, page int32, pageSize int32) ([]cpdomain.Relationship, int64, error)
PrepareBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error)
ConfirmBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, walletTransactionID string, paidCoinAmount int64, coinBalanceAfter int64, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error)
CancelBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, reason string, nowMs int64) (cpdomain.RelationshipBreakup, error)
ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent, nowMs int64) (cpdomain.ConsumeResult, error)
ListActiveIntimacyLeaderboardEntries(ctx context.Context, limit int) ([]cpdomain.IntimacyLeaderboardEntry, error)
ListWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int) ([]cpdomain.WeeklyRankEntry, error)
}
// IntimacyLeaderboardStore 是 CP 亲密榜 Redis zset 读模型边界。
type IntimacyLeaderboardStore interface {
ReplaceIntimacyLeaderboard(ctx context.Context, appCode string, entries []cpdomain.IntimacyLeaderboardEntry) error
ListIntimacyLeaderboard(ctx context.Context, appCode string, relationType string, page int32, pageSize int32, nowMs int64) (cpdomain.IntimacyLeaderboardPage, error)
}
// AvatarFrameReader 批量读取用户当前佩戴头像框;头像框事实仍归 wallet-service。
type AvatarFrameReader interface {
BatchGetAvatarFrames(ctx context.Context, appCode string, userIDs []int64) (map[int64]cpdomain.AvatarFrameSnapshot, error)
}
// Service 只编排 CP 用例,不直接投递 IMIM 由 user_outbox 下游消费者按事实事件处理。
type Service struct {
repo Repository
leaderboardStore IntimacyLeaderboardStore
avatarFrames AvatarFrameReader
clock func() time.Time
}
// Option 按需挂载 CP service 的读模型依赖。
type Option func(*Service)
// WithIntimacyLeaderboardStore 挂载 CP 亲密榜 Redis 读模型。
func WithIntimacyLeaderboardStore(store IntimacyLeaderboardStore) Option {
return func(s *Service) {
s.leaderboardStore = store
}
}
// WithAvatarFrameReader 挂载头像框批量读取器,刷新榜单时一次性补齐双方头像框快照。
func WithAvatarFrameReader(reader AvatarFrameReader) Option {
return func(s *Service) {
s.avatarFrames = reader
}
}
// New 创建 CP 关系 service。
func New(repo Repository, options ...Option) *Service {
service := &Service{
repo: repo,
clock: func() time.Time { return time.Now().UTC() },
}
for _, option := range options {
if option != nil {
option(service)
}
}
return service
}
// ListApplications 返回用户收到或发出的 CP 申请;过期固化在 repository 中完成。
func (s *Service) ListApplications(ctx context.Context, userID int64, direction string, status string, page int32, pageSize int32) ([]cpdomain.Application, int64, int64, error) {
if err := requireUserID(userID); err != nil {
return nil, 0, 0, err
}
nowMs := s.nowMs()
applications, total, err := s.repo.ListApplications(ctx, userID, normalizeDirection(direction), normalizeStatus(status), page, pageSize, nowMs)
return applications, total, nowMs, err
}
// AcceptApplication 同意申请并建立关系;同一用户只能有一个 active 关系的互斥由 repository 事务保证。
func (s *Service) AcceptApplication(ctx context.Context, userID int64, applicationID string) (cpdomain.Application, cpdomain.Relationship, error) {
if err := requireUserID(userID); err != nil {
return cpdomain.Application{}, cpdomain.Relationship{}, err
}
if strings.TrimSpace(applicationID) == "" {
return cpdomain.Application{}, cpdomain.Relationship{}, xerr.New(xerr.InvalidArgument, "application_id is required")
}
return s.repo.AcceptApplication(ctx, userID, strings.TrimSpace(applicationID), s.nowMs())
}
// RejectApplication 拒绝申请;拒绝不阻断同一对用户其他类型的 pending 申请。
func (s *Service) RejectApplication(ctx context.Context, userID int64, applicationID string, reason string) (cpdomain.Application, error) {
if err := requireUserID(userID); err != nil {
return cpdomain.Application{}, err
}
if strings.TrimSpace(applicationID) == "" {
return cpdomain.Application{}, xerr.New(xerr.InvalidArgument, "application_id is required")
}
return s.repo.RejectApplication(ctx, userID, strings.TrimSpace(applicationID), reason, s.nowMs())
}
// ListRelationships 返回用户当前 active 关系;当前产品固定每个用户同时只能有一条关系。
func (s *Service) ListRelationships(ctx context.Context, userID int64, relationType string, page int32, pageSize int32) ([]cpdomain.Relationship, int64, error) {
if err := requireUserID(userID); err != nil {
return nil, 0, err
}
return s.repo.ListRelationships(ctx, userID, normalizeRelationType(relationType), page, pageSize)
}
// PrepareBreakRelationship 创建解除关系占位;占位成功后 gateway 才能去 wallet-service 扣费。
func (s *Service) PrepareBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) {
if err := requireBreakCommand(userID, relationshipID, commandID); err != nil {
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
}
return s.repo.PrepareBreakRelationship(ctx, userID, strings.TrimSpace(relationshipID), strings.TrimSpace(commandID), s.nowMs())
}
// ConfirmBreakRelationship 在 wallet-service 扣费成功后确认解除paidCoinAmount 必须等于 prepare 时冻结的配置费用。
func (s *Service) ConfirmBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, walletTransactionID string, paidCoinAmount int64, coinBalanceAfter int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) {
if err := requireBreakCommand(userID, relationshipID, commandID); err != nil {
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
}
if paidCoinAmount < 0 {
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.InvalidArgument, "paid_coin_amount is invalid")
}
if paidCoinAmount > 0 && strings.TrimSpace(walletTransactionID) == "" {
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.InvalidArgument, "wallet_transaction_id is required")
}
return s.repo.ConfirmBreakRelationship(ctx, userID, strings.TrimSpace(relationshipID), strings.TrimSpace(commandID), strings.TrimSpace(walletTransactionID), paidCoinAmount, coinBalanceAfter, s.nowMs())
}
// CancelBreakRelationship 只取消 pending 占位;已经 confirmed 的解除不能通过取消接口回滚。
func (s *Service) CancelBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, reason string) (cpdomain.RelationshipBreakup, error) {
if err := requireBreakCommand(userID, relationshipID, commandID); err != nil {
return cpdomain.RelationshipBreakup{}, err
}
return s.repo.CancelBreakRelationship(ctx, userID, strings.TrimSpace(relationshipID), strings.TrimSpace(commandID), reason, s.nowMs())
}
// ConsumeGiftEvent 消费 room-service 已扣费并落房间状态的送礼事实。
func (s *Service) ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent) (cpdomain.ConsumeResult, error) {
event.CPRelationType = normalizeRelationType(event.CPRelationType)
// gift_value 是亲密值和周榜分数增量,沿用 room-service 已扣费后的结算值0 值仍可创建申请,但不会增加已有关联亲密值。
if event.GiftValue < 0 {
event.GiftValue = 0
}
return s.repo.ConsumeGiftEvent(ctx, event, s.nowMs())
}
// ListWeeklyRankEntries 给 activity-service 的结算链路读取 CP 周榜快照;它不写 Redis也不发奖。
func (s *Service) ListWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int32) ([]cpdomain.WeeklyRankEntry, int64, error) {
if s == nil || s.repo == nil {
return nil, 0, xerr.New(xerr.Unavailable, "cp repository is not configured")
}
if startMS <= 0 || endMS <= startMS {
return nil, 0, xerr.New(xerr.InvalidArgument, "weekly rank time window is invalid")
}
if limit <= 0 {
limit = 3
}
if limit > 100 {
limit = 100
}
entries, err := s.repo.ListWeeklyRankEntries(ctx, normalizeRelationType(relationType), startMS, endMS, int(limit))
return entries, s.nowMs(), err
}
// RefreshIntimacyLeaderboard 重建 CP 亲密值排行榜读模型cron 只触发,关系、用户资料和头像框快照都由 owner 服务读取。
func (s *Service) RefreshIntimacyLeaderboard(ctx context.Context, limit int) (int, error) {
// 榜单刷新是定时异步链路:任何核心依赖缺失都必须显式失败,让 cron run 记录失败状态,
// 不能静默返回空榜,否则 App 会误以为当前没有亲密关系。
if s == nil || s.repo == nil {
return 0, xerr.New(xerr.Unavailable, "cp repository is not configured")
}
if s.leaderboardStore == nil {
return 0, xerr.New(xerr.Unavailable, "cp intimacy leaderboard store is not configured")
}
if s.avatarFrames == nil {
return 0, xerr.New(xerr.Unavailable, "cp avatar frame reader is not configured")
}
// batch_size 在 cron 配置里表达本轮榜单保留上限service 再做一次夹紧,避免配置异常导致全表无界扫描。
limit = normalizeLeaderboardRowLimit(limit)
entries, err := s.repo.ListActiveIntimacyLeaderboardEntries(ctx, limit)
if err != nil {
return 0, err
}
// 头像框归 wallet-service 管理,关系和用户资料归 user-service 管理;刷新时批量读钱包快照并固化进 zset member
// App 查询榜单时只读 Redis不再跨服务补资料避免高频榜单接口放大 wallet-service 压力。
frames, err := s.avatarFrames.BatchGetAvatarFrames(ctx, appcode.FromContext(ctx), intimacyLeaderboardUserIDs(entries))
if err != nil {
return 0, err
}
applyAvatarFrames(entries, frames)
// Redis 写入采用全量替换语义;关系结束、改名、换头像、换头像框都能在下一轮被覆盖,不需要单独维护删除事件。
if err := s.leaderboardStore.ReplaceIntimacyLeaderboard(ctx, appcode.FromContext(ctx), entries); err != nil {
return 0, err
}
return len(entries), nil
}
// ListIntimacyLeaderboard 返回 cron 刷新的 CP 亲密值排行榜,避免 App 请求实时扫关系表和跨服务补头像框。
func (s *Service) ListIntimacyLeaderboard(ctx context.Context, relationType string, page int32, pageSize int32) (cpdomain.IntimacyLeaderboardPage, error) {
// 查询接口只依赖 Redis 读模型;如果刷新链路没有装配,直接返回 unavailable避免回退 MySQL 实时扫表。
if s == nil || s.leaderboardStore == nil {
return cpdomain.IntimacyLeaderboardPage{}, xerr.New(xerr.Unavailable, "cp intimacy leaderboard store is not configured")
}
// relation_type 只允许 cp/brother/sister空值和非法值都归并到 all 榜,由 Redis store 统一落 key。
relationType = normalizeRelationType(relationType)
page, pageSize = normalizeLeaderboardPage(page, pageSize)
return s.leaderboardStore.ListIntimacyLeaderboard(ctx, appcode.FromContext(ctx), relationType, page, pageSize, s.nowMs())
}
func (s *Service) nowMs() int64 {
if s == nil || s.clock == nil {
return time.Now().UTC().UnixMilli()
}
return s.clock().UTC().UnixMilli()
}
func requireUserID(userID int64) error {
if userID <= 0 {
return xerr.New(xerr.InvalidArgument, "user_id is required")
}
return nil
}
func requireBreakCommand(userID int64, relationshipID string, commandID string) error {
if err := requireUserID(userID); err != nil {
return err
}
if strings.TrimSpace(relationshipID) == "" {
return xerr.New(xerr.InvalidArgument, "relationship_id is required")
}
if strings.TrimSpace(commandID) == "" {
return xerr.New(xerr.InvalidArgument, "command_id is required")
}
if len(strings.TrimSpace(commandID)) > 128 {
return xerr.New(xerr.InvalidArgument, "command_id is too long")
}
return nil
}
func normalizeDirection(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "outgoing":
return "outgoing"
default:
return "incoming"
}
}
func normalizeStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case cpdomain.ApplicationStatusPending,
cpdomain.ApplicationStatusAccepted,
cpdomain.ApplicationStatusRejected,
cpdomain.ApplicationStatusExpired,
cpdomain.ApplicationStatusBlocked:
return strings.ToLower(strings.TrimSpace(value))
default:
return ""
}
}
func normalizeLeaderboardPage(page int32, pageSize int32) (int32, int32) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = defaultIntimacyLeaderboardPageSize
}
if pageSize > maxIntimacyLeaderboardPageSize {
pageSize = maxIntimacyLeaderboardPageSize
}
return page, pageSize
}
func normalizeLeaderboardRowLimit(limit int) int {
if limit <= 0 {
return defaultIntimacyLeaderboardRows
}
if limit > maxIntimacyLeaderboardRows {
return maxIntimacyLeaderboardRows
}
return limit
}
func intimacyLeaderboardUserIDs(entries []cpdomain.IntimacyLeaderboardEntry) []int64 {
seen := make(map[int64]struct{}, len(entries)*2)
userIDs := make([]int64, 0, len(entries)*2)
for _, entry := range entries {
for _, userID := range []int64{entry.UserA.UserID, entry.UserB.UserID} {
if userID <= 0 {
continue
}
// 同一个用户可能出现在多条兄弟/姐妹关系里,批量读钱包资源前先去重,避免无意义的重复 RPC payload。
if _, exists := seen[userID]; exists {
continue
}
seen[userID] = struct{}{}
userIDs = append(userIDs, userID)
}
}
return userIDs
}
func applyAvatarFrames(entries []cpdomain.IntimacyLeaderboardEntry, frames map[int64]cpdomain.AvatarFrameSnapshot) {
for index := range entries {
// 没有佩戴头像框或 wallet-service 没返回资源时保持 nilgateway 会省略 avatar_frame 字段。
if frame, ok := frames[entries[index].UserA.UserID]; ok && frame.ResourceID > 0 {
entries[index].UserA.AvatarFrame = &frame
}
if frame, ok := frames[entries[index].UserB.UserID]; ok && frame.ResourceID > 0 {
entries[index].UserB.AvatarFrame = &frame
}
}
}
func normalizeRelationType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case cpdomain.RelationTypeCP:
return cpdomain.RelationTypeCP
case cpdomain.RelationTypeBrother:
return cpdomain.RelationTypeBrother
case cpdomain.RelationTypeSister:
return cpdomain.RelationTypeSister
default:
return ""
}
}