package cpweeklyrank import ( "context" "fmt" "log/slog" "sort" "strings" "time" "google.golang.org/grpc" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/logx" "hyapp/pkg/xerr" domain "hyapp/services/activity-service/internal/domain/cpweeklyrank" ) const cpWeeklyRankRewardReason = "cp weekly rank reward" // Repository is the activity-service storage boundary for CP weekly rank config and settlement facts. type Repository interface { GetCPWeeklyRankConfig(ctx context.Context) (domain.Config, bool, error) UpdateCPWeeklyRankConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error) ListCPWeeklyRankSettlements(ctx context.Context, periodStartMS int64, periodEndMS int64) ([]domain.Settlement, error) PrepareCPWeeklyRankSettlements(ctx context.Context, config domain.Config, entries []domain.Entry, periodStartMS int64, periodEndMS int64, nowMS int64) ([]domain.Settlement, error) ListPendingCPWeeklyRankSettlements(ctx context.Context, periodStartMS int64, periodEndMS int64, limit int) ([]domain.Settlement, error) MarkCPWeeklyRankSettlementGranted(ctx context.Context, settlementID string, walletGrantID string, nowMS int64) error MarkCPWeeklyRankSettlementFailed(ctx context.Context, settlementID string, reason string, nowMS int64) error } // RankSource reads user-service owned CP weekly score snapshots; activity-service never scans user DB directly. type RankSource interface { ListCPWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int32) ([]domain.Entry, error) } // WalletClient owns the external resource-group grant side effect. type WalletClient interface { GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) } // Service owns CP weekly rank config, H5 reads and weekly reward settlement. type Service struct { repository Repository rankSource RankSource wallet WalletClient now func() time.Time } func New(repository Repository, rankSource RankSource, wallet WalletClient) *Service { return &Service{repository: repository, rankSource: rankSource, wallet: wallet, now: time.Now} } func (s *Service) SetClock(now func() time.Time) { if now != nil { s.now = now } } func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) { if err := s.requireRepository(); err != nil { return domain.Config{}, err } config, exists, err := s.repository.GetCPWeeklyRankConfig(ctx) if err != nil { return domain.Config{}, err } if !exists { return defaultConfig(ctx), nil } return normalizeConfigForRead(config), nil } func (s *Service) UpdateConfig(ctx context.Context, config domain.Config, operatorAdminID int64) (domain.Config, error) { if err := s.requireRepository(); err != nil { return domain.Config{}, err } if operatorAdminID <= 0 { return domain.Config{}, xerr.New(xerr.InvalidArgument, "operator_admin_id is required") } config.AppCode = appcode.FromContext(ctx) config.ActivityCode = domain.ActivityCode config.RelationType = "cp" config.UpdatedByAdminID = operatorAdminID config = normalizeConfigForWrite(config) if err := validateConfig(config); err != nil { return domain.Config{}, err } return s.repository.UpdateCPWeeklyRankConfig(ctx, config, s.now().UTC().UnixMilli()) } func (s *Service) GetStatus(ctx context.Context, limit int32, nowMS int64) (domain.Status, error) { if err := s.requireRepository(); err != nil { return domain.Status{}, err } if s.rankSource == nil { return domain.Status{}, xerr.New(xerr.Unavailable, "cp weekly rank source is not configured") } now := s.now().UTC() if nowMS > 0 { now = time.UnixMilli(nowMS).UTC() } start, end := WeekWindow(now) config, err := s.GetConfig(ctx) if err != nil { return domain.Status{}, err } result := domain.Status{ Config: config, PeriodStartMS: start.UnixMilli(), PeriodEndMS: end.UnixMilli(), ServerTimeMS: now.UnixMilli(), } if !config.Enabled { return result, nil } if limit <= 0 || limit > config.TopCount { limit = config.TopCount } if limit <= 0 { limit = 3 } // H5 同时需要当前周期榜单和上一完整 UTC 周期的前三名;两个窗口都从 user-service 的周期新增 gift_value 聚合读取,避免 gateway/H5 误用累计亲密榜。 entries, err := s.rankSource.ListCPWeeklyRankEntries(ctx, config.RelationType, result.PeriodStartMS, result.PeriodEndMS, limit) if err != nil { return domain.Status{}, err } result.Entries = entries previousStart, previousEnd := PreviousWeekWindow(now) previousLimit := int32(3) previousEntries, err := s.rankSource.ListCPWeeklyRankEntries(ctx, config.RelationType, previousStart.UnixMilli(), previousEnd.UnixMilli(), previousLimit) if err != nil { return domain.Status{}, err } result.PreviousEntries = previousEntries return result, nil } func (s *Service) ListSettlements(ctx context.Context, periodStartMS int64, periodEndMS int64) ([]domain.Settlement, error) { if err := s.requireRepository(); err != nil { return nil, err } return s.repository.ListCPWeeklyRankSettlements(ctx, periodStartMS, periodEndMS) } // ProcessSettlementBatch freezes the previous complete UTC week and grants each Top relationship reward to both users. // The rank source already returns weekly delta score, so settlement only persists that frozen score and never re-reads cumulative intimacy. func (s *Service) ProcessSettlementBatch(ctx context.Context, runID string, workerID string, batchSize int32) (claimed int32, processed int32, success int32, failure int32, hasMore bool, err error) { if err := s.requireRepository(); err != nil { return 0, 0, 0, 0, false, err } if s.rankSource == nil { return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "cp weekly rank source is not configured") } if s.wallet == nil { return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "wallet client is not configured") } if batchSize <= 0 { batchSize = 50 } config, err := s.GetConfig(ctx) if err != nil { return 0, 0, 0, 0, false, err } if !config.Enabled || len(config.Rewards) == 0 { return 0, 0, 0, 0, false, nil } periodStart, periodEnd := PreviousWeekWindow(s.now().UTC()) entries, err := s.rankSource.ListCPWeeklyRankEntries(ctx, config.RelationType, periodStart.UnixMilli(), periodEnd.UnixMilli(), config.TopCount) if err != nil { return 0, 0, 0, 0, false, err } settlements, err := s.repository.PrepareCPWeeklyRankSettlements(ctx, config, entries, periodStart.UnixMilli(), periodEnd.UnixMilli(), s.now().UTC().UnixMilli()) if err != nil { return 0, 0, 0, 0, false, err } claimed = int32(len(settlements)) pending, err := s.repository.ListPendingCPWeeklyRankSettlements(ctx, periodStart.UnixMilli(), periodEnd.UnixMilli(), int(batchSize)) if err != nil { return claimed, 0, 0, 0, false, err } hasMore = len(pending) >= int(batchSize) for _, settlement := range pending { processed++ // activity-service 只决定 CP 周榜获奖关系和资源组,wallet-service 负责展开资源组和写 grant 流水。 // 失败时保持同一个 wallet_command_id 回到 failed,下一轮 cron 继续重试,防止网络抖动造成重复发奖。 resp, grantErr := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{ CommandId: settlement.WalletCommandID, AppCode: appcode.FromContext(ctx), TargetUserId: settlement.UserID, GroupId: settlement.ResourceGroupID, Reason: cpWeeklyRankRewardReason, OperatorUserId: settlement.UserID, GrantSource: domain.GrantSourceCPWeeklyRank, }) if grantErr != nil { logx.Error(ctx, "cp_weekly_rank_grant_resource_group_failed", grantErr, slog.String("settlement_id", settlement.SettlementID), slog.String("run_id", runID), slog.String("worker_id", workerID)) failure++ _ = s.repository.MarkCPWeeklyRankSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(grantErr), s.now().UTC().UnixMilli()) continue } walletGrantID := "" if resp.GetGrant() != nil { walletGrantID = resp.GetGrant().GetGrantId() } if err := s.repository.MarkCPWeeklyRankSettlementGranted(ctx, settlement.SettlementID, walletGrantID, s.now().UTC().UnixMilli()); err != nil { logx.Error(ctx, "cp_weekly_rank_mark_granted_failed", err, slog.String("settlement_id", settlement.SettlementID), slog.String("wallet_grant_id", walletGrantID)) failure++ continue } success++ } return claimed, processed, success, failure, hasMore, nil } func (s *Service) requireRepository() error { if s == nil || s.repository == nil { return xerr.New(xerr.Unavailable, "cp weekly rank repository is not configured") } return nil } func defaultConfig(ctx context.Context) domain.Config { return domain.Config{ AppCode: appcode.FromContext(ctx), ActivityCode: domain.ActivityCode, Enabled: true, RelationType: "cp", TopCount: 3, } } func normalizeConfigForRead(config domain.Config) domain.Config { config.ActivityCode = domain.ActivityCode config.RelationType = "cp" if config.TopCount <= 0 { config.TopCount = 3 } config.Rewards = normalizeRewards(config.Rewards, config.TopCount) return config } func normalizeConfigForWrite(config domain.Config) domain.Config { if config.TopCount <= 0 { config.TopCount = 3 } if config.TopCount > 20 { config.TopCount = 20 } config.Rewards = normalizeRewards(config.Rewards, config.TopCount) return config } func normalizeRewards(rewards []domain.Reward, topCount int32) []domain.Reward { seen := make(map[int32]domain.Reward, len(rewards)) for _, reward := range rewards { if reward.RankNo <= 0 || reward.RankNo > topCount || reward.ResourceGroupID <= 0 { continue } reward.RankNo = int32(reward.RankNo) seen[reward.RankNo] = reward } normalized := make([]domain.Reward, 0, len(seen)) for _, reward := range seen { normalized = append(normalized, reward) } sort.Slice(normalized, func(i, j int) bool { return normalized[i].RankNo < normalized[j].RankNo }) return normalized } func validateConfig(config domain.Config) error { if config.Enabled && len(config.Rewards) == 0 { return xerr.New(xerr.InvalidArgument, "cp weekly rank rewards are required when enabled") } for _, reward := range config.Rewards { if reward.RankNo <= 0 || reward.RankNo > config.TopCount { return xerr.New(xerr.InvalidArgument, "cp weekly rank reward rank_no is invalid") } if reward.ResourceGroupID <= 0 { return xerr.New(xerr.InvalidArgument, "cp weekly rank reward resource_group_id is required") } } return nil } // WeekWindow returns the current UTC week [Monday 00:00, next Monday 00:00). func WeekWindow(now time.Time) (time.Time, time.Time) { dayStart := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC) weekday := int(dayStart.Weekday()) if weekday == 0 { weekday = 7 } start := dayStart.AddDate(0, 0, 1-weekday) return start, start.AddDate(0, 0, 7) } // PreviousWeekWindow returns the complete UTC week immediately before now. func PreviousWeekWindow(now time.Time) (time.Time, time.Time) { currentStart, _ := WeekWindow(now) previousStart := currentStart.AddDate(0, 0, -7) return previousStart, currentStart } func WalletCommandID(periodStartMS int64, rankNo int32, relationshipID string, userID int64) string { return fmt.Sprintf("cp_weekly_rank:%d:%d:%s:%d", periodStartMS, rankNo, strings.TrimSpace(relationshipID), userID) }