2026-06-25 15:07:46 +08:00

307 lines
11 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 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)
}