2026-06-15 23:53:30 +08:00

362 lines
16 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 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.GetIsRobotGift() {
return 0, nil
}
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
}
}