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