359 lines
16 KiB
Go
359 lines
16 KiB
Go
package weeklystar
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"google.golang.org/grpc"
|
||
"google.golang.org/protobuf/proto"
|
||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/logx"
|
||
"hyapp/pkg/xerr"
|
||
domain "hyapp/services/activity-service/internal/domain/weeklystar"
|
||
)
|
||
|
||
const weeklyStarRewardReason = "weekly star reward"
|
||
|
||
// Repository is the storage boundary for weekly-star configuration, score and settlement facts.
|
||
type Repository interface {
|
||
ListWeeklyStarCycles(ctx context.Context, query domain.ListQuery) ([]domain.Cycle, int64, error)
|
||
GetWeeklyStarCycle(ctx context.Context, cycleID string) (domain.Cycle, error)
|
||
UpsertWeeklyStarCycle(ctx context.Context, command domain.CycleCommand, nowMS int64) (domain.Cycle, bool, error)
|
||
SetWeeklyStarCycleStatus(ctx context.Context, cycleID string, status string, operatorAdminID int64, nowMS int64) (domain.Cycle, error)
|
||
FindCurrentWeeklyStarCycle(ctx context.Context, regionID int64, nowMS int64) (domain.Cycle, bool, error)
|
||
ConsumeWeeklyStarGiftEvent(ctx context.Context, event domain.GiftEvent, nowMS int64) (domain.EventResult, error)
|
||
ListWeeklyStarLeaderboard(ctx context.Context, cycleID string, regionID int64, pageSize int32, pageToken string, nowMS int64) (domain.Cycle, []domain.LeaderboardEntry, string, int64, error)
|
||
GetWeeklyStarUserEntry(ctx context.Context, cycleID string, userID int64) (domain.LeaderboardEntry, bool, error)
|
||
ListWeeklyStarHistory(ctx context.Context, regionID int64, limit int32, nowMS int64) ([]domain.HistoryCycle, error)
|
||
ListWeeklyStarSettlements(ctx context.Context, cycleID string) ([]domain.Settlement, error)
|
||
ClaimDueWeeklyStarCycles(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.Cycle, error)
|
||
PrepareWeeklyStarSettlements(ctx context.Context, cycleID string, nowMS int64) ([]domain.Settlement, error)
|
||
MarkWeeklyStarSettlementGranted(ctx context.Context, settlementID string, walletGrantID string, nowMS int64) error
|
||
MarkWeeklyStarSettlementFailed(ctx context.Context, settlementID string, reason string, nowMS int64) error
|
||
FinishWeeklyStarCycleIfComplete(ctx context.Context, cycleID string, nowMS int64) (bool, error)
|
||
}
|
||
|
||
// WalletClient is the small wallet-service surface needed by weekly-star settlement.
|
||
type WalletClient interface {
|
||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
|
||
}
|
||
|
||
// Service owns weekly-star business rules; transports and cron only adapt inputs into this API.
|
||
type Service struct {
|
||
repository Repository
|
||
wallet WalletClient
|
||
now func() time.Time
|
||
}
|
||
|
||
// New creates the weekly-star service with activity-service storage as the source of truth.
|
||
func New(repository Repository, wallet WalletClient) *Service {
|
||
return &Service{
|
||
repository: repository,
|
||
wallet: wallet,
|
||
now: time.Now,
|
||
}
|
||
}
|
||
|
||
// ListCycles returns configured cycles for admin pages without leaking storage paging rules to transport.
|
||
func (s *Service) ListCycles(ctx context.Context, query domain.ListQuery) ([]domain.Cycle, int64, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
query.Status = normalizeStatus(query.Status)
|
||
return s.repository.ListWeeklyStarCycles(ctx, query)
|
||
}
|
||
|
||
// GetCycle returns a single admin cycle, including the three gifts and Top rewards.
|
||
func (s *Service) GetCycle(ctx context.Context, cycleID string) (domain.Cycle, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Cycle{}, err
|
||
}
|
||
cycleID = strings.TrimSpace(cycleID)
|
||
if cycleID == "" {
|
||
return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "cycle_id is required")
|
||
}
|
||
return s.repository.GetWeeklyStarCycle(ctx, cycleID)
|
||
}
|
||
|
||
// CreateCycle validates the full weekly-star contract and stores a new UTC cycle.
|
||
func (s *Service) CreateCycle(ctx context.Context, command domain.CycleCommand) (domain.Cycle, bool, error) {
|
||
command.RequireNewRecord = true
|
||
return s.upsertCycle(ctx, command)
|
||
}
|
||
|
||
// UpdateCycle replaces an existing weekly-star cycle after the same create-time validations pass.
|
||
func (s *Service) UpdateCycle(ctx context.Context, command domain.CycleCommand) (domain.Cycle, bool, error) {
|
||
command.RequireNewRecord = false
|
||
return s.upsertCycle(ctx, command)
|
||
}
|
||
|
||
// SetCycleStatus switches admin-visible status; active cycles are the only ones matched by gift events.
|
||
func (s *Service) SetCycleStatus(ctx context.Context, cycleID string, status string, operatorAdminID int64) (domain.Cycle, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Cycle{}, err
|
||
}
|
||
status = normalizeStatus(status)
|
||
if !validStatus(status) || status == domain.StatusSettling {
|
||
return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "status is invalid")
|
||
}
|
||
if operatorAdminID <= 0 {
|
||
return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
|
||
}
|
||
return s.repository.SetWeeklyStarCycleStatus(ctx, strings.TrimSpace(cycleID), status, operatorAdminID, s.now().UnixMilli())
|
||
}
|
||
|
||
// GetCurrent resolves the current cycle by region, using the storage-level concrete-region-first/default fallback.
|
||
func (s *Service) GetCurrent(ctx context.Context, userID int64, regionID int64) (domain.Cycle, []domain.LeaderboardEntry, domain.LeaderboardEntry, bool, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Cycle{}, nil, domain.LeaderboardEntry{}, false, err
|
||
}
|
||
cycle, ok, err := s.repository.FindCurrentWeeklyStarCycle(ctx, regionID, s.now().UnixMilli())
|
||
if err != nil || !ok {
|
||
return cycle, nil, domain.LeaderboardEntry{}, false, err
|
||
}
|
||
_, top, _, _, err := s.repository.ListWeeklyStarLeaderboard(ctx, cycle.CycleID, regionID, 50, "", s.now().UnixMilli())
|
||
if err != nil {
|
||
return domain.Cycle{}, nil, domain.LeaderboardEntry{}, false, err
|
||
}
|
||
myEntry, found, err := s.repository.GetWeeklyStarUserEntry(ctx, cycle.CycleID, userID)
|
||
return cycle, top, myEntry, found, err
|
||
}
|
||
|
||
// ListLeaderboard returns deterministic score order for either a concrete cycle or current region cycle.
|
||
func (s *Service) ListLeaderboard(ctx context.Context, cycleID string, regionID int64, pageSize int32, pageToken string) (domain.Cycle, []domain.LeaderboardEntry, string, int64, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Cycle{}, nil, "", 0, err
|
||
}
|
||
return s.repository.ListWeeklyStarLeaderboard(ctx, strings.TrimSpace(cycleID), regionID, pageSize, strings.TrimSpace(pageToken), s.now().UnixMilli())
|
||
}
|
||
|
||
// ListHistory returns settled cycles for the user region with default-cycle fallback handled by storage.
|
||
func (s *Service) ListHistory(ctx context.Context, regionID int64, limit int32) ([]domain.HistoryCycle, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.repository.ListWeeklyStarHistory(ctx, regionID, limit, s.now().UnixMilli())
|
||
}
|
||
|
||
// ListSettlements returns admin-visible settlement grant facts for one cycle.
|
||
func (s *Service) ListSettlements(ctx context.Context, cycleID string) ([]domain.Settlement, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return nil, err
|
||
}
|
||
cycleID = strings.TrimSpace(cycleID)
|
||
if cycleID == "" {
|
||
return nil, xerr.New(xerr.InvalidArgument, "cycle_id is required")
|
||
}
|
||
return s.repository.ListWeeklyStarSettlements(ctx, cycleID)
|
||
}
|
||
|
||
// HandleRoomEvent consumes room-service committed gift facts; score always equals the actual coin_spent.
|
||
// 这里不读取 gift 配置价格,也不相信客户端传入积分,因为钱包扣费后的 room outbox 才是送礼成功事实。
|
||
// 非 RoomGiftSent、无用户、无礼物或无扣费金额的 envelope 直接跳过,避免让普通房间事件污染活动积分表。
|
||
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (int32, error) {
|
||
if s == nil || s.repository == nil || envelope == nil || envelope.GetEventType() != "RoomGiftSent" {
|
||
return 0, nil
|
||
}
|
||
var gift roomeventsv1.RoomGiftSent
|
||
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
|
||
return 0, err
|
||
}
|
||
if gift.GetSenderUserId() <= 0 || gift.GetGiftId() == "" || gift.GetCoinSpent() <= 0 {
|
||
return 0, nil
|
||
}
|
||
// region_id 和 occurred_at_ms 都来自 room-service 的稳定事实;repository 会按事件发生时间匹配 [start_ms,end_ms) 周期。
|
||
// source_event_id 是幂等键,同一个 room outbox 重试时只会插入一次 score event,并且不会重复累加榜单分数。
|
||
event := domain.GiftEvent{
|
||
EventID: strings.TrimSpace(envelope.GetEventId()),
|
||
UserID: gift.GetSenderUserId(),
|
||
GiftID: strings.TrimSpace(gift.GetGiftId()),
|
||
ScoreDelta: gift.GetCoinSpent(),
|
||
RegionID: gift.GetVisibleRegionId(),
|
||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||
}
|
||
result, err := s.repository.ConsumeWeeklyStarGiftEvent(appcode.WithContext(ctx, envelope.GetAppCode()), event, s.now().UnixMilli())
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if result.Status == domain.EventStatusConsumed {
|
||
return 1, nil
|
||
}
|
||
return 0, nil
|
||
}
|
||
|
||
// ProcessSettlementBatch locks due cycles, materializes Top1/Top2/Top3, and grants each resource group idempotently.
|
||
// 状态机是 active -> settling -> settled:先锁到期周期,再把榜单快照固化为 settlement,最后逐条调用 wallet 发资源组。
|
||
// wallet_command_id 使用 cycle/rank/user 组成,cron 重试同一 settlement 时只会命中 wallet 幂等,不会重复发奖励。
|
||
func (s *Service) ProcessSettlementBatch(ctx context.Context, runID string, workerID string, batchSize int32, lockTTL time.Duration) (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.wallet == nil {
|
||
return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||
}
|
||
if batchSize <= 0 {
|
||
batchSize = 50
|
||
}
|
||
if lockTTL <= 0 {
|
||
lockTTL = 30 * time.Second
|
||
}
|
||
if strings.TrimSpace(workerID) == "" {
|
||
workerID = "activity-weekly-star"
|
||
}
|
||
cycles, err := s.repository.ClaimDueWeeklyStarCycles(ctx, workerID, s.now().UnixMilli(), lockTTL, batchSize)
|
||
if err != nil {
|
||
return 0, 0, 0, 0, false, err
|
||
}
|
||
for _, cycle := range cycles {
|
||
settlements, prepareErr := s.repository.PrepareWeeklyStarSettlements(ctx, cycle.CycleID, s.now().UnixMilli())
|
||
if prepareErr != nil {
|
||
logx.Error(ctx, "weekly_star_prepare_settlements_failed", prepareErr, slog.String("cycle_id", cycle.CycleID), slog.String("run_id", runID), slog.String("worker_id", workerID))
|
||
failure++
|
||
continue
|
||
}
|
||
if len(settlements) == 0 {
|
||
// 没有人得分的周期不会生成 settlement;只要没有 pending 记录,就可以安全标记为 settled。
|
||
if _, finishErr := s.repository.FinishWeeklyStarCycleIfComplete(ctx, cycle.CycleID, s.now().UnixMilli()); finishErr != nil {
|
||
failure++
|
||
continue
|
||
}
|
||
success++
|
||
processed++
|
||
continue
|
||
}
|
||
for _, settlement := range settlements {
|
||
processed++
|
||
// activity-service 只决定获奖人和资源组,真实发放由 wallet-service 原子展开资源组并写 grant/账务流水。
|
||
// 如果 wallet 临时失败,settlement 会退回 failed 状态,下一轮 cron 继续用同一个 wallet_command_id 重试。
|
||
resp, grantErr := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
|
||
CommandId: settlement.WalletCommandID,
|
||
AppCode: appcode.FromContext(ctx),
|
||
TargetUserId: settlement.UserID,
|
||
GroupId: settlement.ResourceGroupID,
|
||
Reason: weeklyStarRewardReason,
|
||
OperatorUserId: settlement.UserID,
|
||
GrantSource: domain.GrantSourceWeeklyStar,
|
||
})
|
||
if grantErr != nil {
|
||
logx.Error(ctx, "weekly_star_grant_resource_group_failed", grantErr, slog.String("cycle_id", cycle.CycleID), slog.String("settlement_id", settlement.SettlementID), slog.String("wallet_command_id", settlement.WalletCommandID))
|
||
failure++
|
||
_ = s.repository.MarkWeeklyStarSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(grantErr), s.now().UnixMilli())
|
||
continue
|
||
}
|
||
walletGrantID := ""
|
||
if resp.GetGrant() != nil {
|
||
walletGrantID = resp.GetGrant().GetGrantId()
|
||
}
|
||
if markErr := s.repository.MarkWeeklyStarSettlementGranted(ctx, settlement.SettlementID, walletGrantID, s.now().UnixMilli()); markErr != nil {
|
||
logx.Error(ctx, "weekly_star_mark_settlement_granted_failed", markErr, slog.String("cycle_id", cycle.CycleID), slog.String("settlement_id", settlement.SettlementID), slog.String("wallet_grant_id", walletGrantID))
|
||
failure++
|
||
continue
|
||
}
|
||
success++
|
||
}
|
||
if _, finishErr := s.repository.FinishWeeklyStarCycleIfComplete(ctx, cycle.CycleID, s.now().UnixMilli()); finishErr != nil {
|
||
// Finish 会再次检查所有 settlement 是否 granted,防止部分奖励失败时把周期提前关掉。
|
||
failure++
|
||
}
|
||
}
|
||
return int32(len(cycles)), processed, success, failure, len(cycles) == int(batchSize), nil
|
||
}
|
||
|
||
func (s *Service) upsertCycle(ctx context.Context, command domain.CycleCommand) (domain.Cycle, bool, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Cycle{}, false, err
|
||
}
|
||
command.Cycle = normalizeCycle(command.Cycle)
|
||
if command.OperatorAdminID <= 0 {
|
||
return domain.Cycle{}, false, xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
|
||
}
|
||
if err := validateCycle(command.Cycle); err != nil {
|
||
return domain.Cycle{}, false, err
|
||
}
|
||
return s.repository.UpsertWeeklyStarCycle(ctx, command, s.now().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) requireRepository() error {
|
||
if s == nil || s.repository == nil {
|
||
return xerr.New(xerr.Unavailable, "weekly star repository is not configured")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeCycle(cycle domain.Cycle) domain.Cycle {
|
||
cycle.ActivityCode = domain.ActivityCode
|
||
cycle.CycleID = strings.TrimSpace(cycle.CycleID)
|
||
cycle.Title = strings.TrimSpace(cycle.Title)
|
||
cycle.Status = normalizeStatus(cycle.Status)
|
||
if cycle.Status == "" {
|
||
cycle.Status = domain.StatusDraft
|
||
}
|
||
seenGifts := map[string]bool{}
|
||
gifts := make([]domain.Gift, 0, len(cycle.Gifts))
|
||
for _, gift := range cycle.Gifts {
|
||
gift.GiftID = strings.TrimSpace(gift.GiftID)
|
||
if gift.GiftID == "" || seenGifts[gift.GiftID] {
|
||
continue
|
||
}
|
||
seenGifts[gift.GiftID] = true
|
||
gifts = append(gifts, gift)
|
||
}
|
||
sort.SliceStable(gifts, func(i, j int) bool { return gifts[i].SortOrder < gifts[j].SortOrder })
|
||
for index := range gifts {
|
||
if gifts[index].SortOrder <= 0 {
|
||
gifts[index].SortOrder = int32(index + 1)
|
||
}
|
||
}
|
||
cycle.Gifts = gifts
|
||
sort.SliceStable(cycle.Rewards, func(i, j int) bool { return cycle.Rewards[i].RankNo < cycle.Rewards[j].RankNo })
|
||
return cycle
|
||
}
|
||
|
||
func validateCycle(cycle domain.Cycle) error {
|
||
if cycle.Title == "" {
|
||
return xerr.New(xerr.InvalidArgument, "title is required")
|
||
}
|
||
if cycle.RegionID < 0 {
|
||
return xerr.New(xerr.InvalidArgument, "region_id is invalid")
|
||
}
|
||
if cycle.StartMS <= 0 || cycle.EndMS <= cycle.StartMS {
|
||
return xerr.New(xerr.InvalidArgument, "start_ms and end_ms are invalid")
|
||
}
|
||
if !validStatus(cycle.Status) || cycle.Status == domain.StatusSettling || cycle.Status == domain.StatusSettled {
|
||
return xerr.New(xerr.InvalidArgument, "status is invalid")
|
||
}
|
||
if len(cycle.Gifts) != 3 {
|
||
return xerr.New(xerr.InvalidArgument, "weekly star requires exactly 3 gifts")
|
||
}
|
||
rankToGroup := map[int32]int64{}
|
||
for _, reward := range cycle.Rewards {
|
||
if reward.RankNo >= 1 && reward.RankNo <= 3 && reward.ResourceGroupID > 0 {
|
||
rankToGroup[reward.RankNo] = reward.ResourceGroupID
|
||
}
|
||
}
|
||
for rank := int32(1); rank <= 3; rank++ {
|
||
if rankToGroup[rank] <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "top1/top2/top3 resource groups are required")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeStatus(value string) string {
|
||
return strings.ToLower(strings.TrimSpace(value))
|
||
}
|
||
|
||
func validStatus(value string) bool {
|
||
switch value {
|
||
case domain.StatusDraft, domain.StatusActive, domain.StatusDisabled, domain.StatusSettling, domain.StatusSettled:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|