521 lines
21 KiB
Go
521 lines
21 KiB
Go
package roomturnoverreward
|
||
|
||
import (
|
||
"context"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"google.golang.org/grpc"
|
||
"google.golang.org/protobuf/proto"
|
||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||
roomv1 "hyapp.local/api/proto/room/v1"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
|
||
)
|
||
|
||
const rewardReason = "room_turnover_reward"
|
||
|
||
// Repository 是房间流水奖励配置、周期聚合和结算记录的唯一持久化边界。
|
||
type Repository interface {
|
||
GetRoomTurnoverRewardConfig(ctx context.Context) (domain.Config, bool, error)
|
||
UpdateRoomTurnoverRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
|
||
ConsumeRoomTurnoverGiftEvent(ctx context.Context, event domain.RoomGiftEvent, periodStartMS int64, periodEndMS int64, nowMS int64) (domain.EventResult, error)
|
||
GetRoomTurnoverRewardProgress(ctx context.Context, roomID string, periodStartMS int64) (domain.Progress, bool, error)
|
||
GetLatestRoomTurnoverRewardSettlement(ctx context.Context, roomID string) (domain.Settlement, bool, error)
|
||
ListRoomTurnoverRewardSettlements(ctx context.Context, query domain.SettlementQuery) ([]domain.Settlement, int64, error)
|
||
ListUnsettledRoomTurnoverRewardProgress(ctx context.Context, periodStartMS int64, limit int) ([]domain.Progress, error)
|
||
InsertRoomTurnoverRewardSettlement(ctx context.Context, settlement domain.Settlement, nowMS int64) (domain.Settlement, bool, error)
|
||
ListPendingRoomTurnoverRewardSettlements(ctx context.Context, limit int) ([]domain.Settlement, error)
|
||
GetRoomTurnoverRewardSettlement(ctx context.Context, settlementID string) (domain.Settlement, bool, error)
|
||
ResetRoomTurnoverRewardSettlementForRetry(ctx context.Context, settlementID string, ownerUserID int64, nowMS int64) (domain.Settlement, error)
|
||
MarkRoomTurnoverRewardSettlementGranted(ctx context.Context, settlementID string, walletTransactionID string, grantedAtMS int64) (domain.Settlement, error)
|
||
MarkRoomTurnoverRewardSettlementFailed(ctx context.Context, settlementID string, reason string, nowMS int64) error
|
||
}
|
||
|
||
// WalletClient 是房间流水奖励唯一外部发币副作用。
|
||
type WalletClient interface {
|
||
CreditRoomTurnoverReward(ctx context.Context, req *walletv1.CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error)
|
||
}
|
||
|
||
// RoomQueryClient 只读取 room-service 的房主快照,不拥有房间状态。
|
||
type RoomQueryClient interface {
|
||
AdminGetRoom(ctx context.Context, req *roomv1.AdminGetRoomRequest, opts ...grpc.CallOption) (*roomv1.AdminGetRoomResponse, error)
|
||
}
|
||
|
||
// Service 承载房间流水奖励 App 查询、后台配置、事件聚合和每周结算。
|
||
type Service struct {
|
||
repository Repository
|
||
wallet WalletClient
|
||
room RoomQueryClient
|
||
now func() time.Time
|
||
}
|
||
|
||
func New(repository Repository, wallet WalletClient, room RoomQueryClient) *Service {
|
||
return &Service{repository: repository, wallet: wallet, room: room, 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.GetRoomTurnoverRewardConfig(ctx)
|
||
if err != nil {
|
||
return domain.Config{}, err
|
||
}
|
||
if !exists {
|
||
return domain.Config{AppCode: appcode.FromContext(ctx)}, nil
|
||
}
|
||
return config, nil
|
||
}
|
||
|
||
func (s *Service) UpdateConfig(ctx context.Context, config domain.Config) (domain.Config, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Config{}, err
|
||
}
|
||
config.AppCode = appcode.FromContext(ctx)
|
||
config.Tiers = normalizeTiers(config.Tiers)
|
||
if err := validateConfig(config); err != nil {
|
||
return domain.Config{}, err
|
||
}
|
||
return s.repository.UpdateRoomTurnoverRewardConfig(ctx, config, s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) GetStatus(ctx context.Context, userID int64, roomID string, ownerUserID int64) (domain.StatusResult, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.StatusResult{}, err
|
||
}
|
||
if userID <= 0 {
|
||
return domain.StatusResult{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||
}
|
||
// App 展示和每周结算必须使用同一套 UTC 周窗口;这里用 service clock 统一可测试时间源,避免 H5、本地测试和 cron 看到不同周期。
|
||
now := s.now().UTC()
|
||
start, end := WeekWindow(now)
|
||
result := domain.StatusResult{
|
||
RoomID: strings.TrimSpace(roomID),
|
||
OwnerUserID: ownerUserID,
|
||
PeriodStartMS: start.UnixMilli(),
|
||
PeriodEndMS: end.UnixMilli(),
|
||
ServerTimeMS: now.UnixMilli(),
|
||
}
|
||
config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx)
|
||
if err != nil {
|
||
return domain.StatusResult{}, err
|
||
}
|
||
if exists {
|
||
config.Tiers = sortTiersForDisplay(config.Tiers)
|
||
result.Config = config
|
||
} else {
|
||
// 未配置不是异常:后台首次进入或活动未上线时,H5 仍需要周期时间和空档位来渲染默认态。
|
||
result.Config = domain.Config{AppCode: appcode.FromContext(ctx)}
|
||
}
|
||
if result.RoomID == "" {
|
||
// 当前用户没有自己的房间时不能查询流水聚合;直接返回配置和周期,让 H5 显示无房间/未参与状态。
|
||
return result, nil
|
||
}
|
||
progress, found, err := s.repository.GetRoomTurnoverRewardProgress(ctx, result.RoomID, result.PeriodStartMS)
|
||
if err != nil {
|
||
return domain.StatusResult{}, err
|
||
}
|
||
if found {
|
||
result.CurrentCoinSpent = progress.CoinSpent
|
||
}
|
||
if tier, matched := MatchHighestTier(activeTiers(result.Config.Tiers), result.CurrentCoinSpent); matched {
|
||
result.MatchedTier = tier
|
||
result.ExpectedRewardCoinAmount = tier.RewardCoinAmount
|
||
}
|
||
if settlement, found, err := s.repository.GetLatestRoomTurnoverRewardSettlement(ctx, result.RoomID); err != nil {
|
||
return domain.StatusResult{}, err
|
||
} else if found {
|
||
// 最近结算不限定当前周期;周切换后当前流水会清零,但 H5 仍要展示上一周期是否已经发奖或失败。
|
||
result.LatestSettlement = settlement
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// HandleRoomEvent 把 room-service 已提交送礼事实聚合到对应 UTC 周期房间流水。
|
||
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (domain.EventResult, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.EventResult{}, err
|
||
}
|
||
if envelope == nil {
|
||
return domain.EventResult{}, xerr.New(xerr.InvalidArgument, "room event envelope is required")
|
||
}
|
||
if envelope.GetEventType() != "RoomGiftSent" {
|
||
// activity-service 订阅的是 room outbox 大类事件;非送礼事实不参与流水奖励,直接标记 skipped,避免无意义重试。
|
||
return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
|
||
}
|
||
var gift roomeventsv1.RoomGiftSent
|
||
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
|
||
return domain.EventResult{}, err
|
||
}
|
||
if gift.GetIsRobotGift() {
|
||
return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
|
||
}
|
||
if strings.TrimSpace(envelope.GetRoomId()) == "" || gift.GetCoinSpent() <= 0 {
|
||
// 缺房间或 0 金币不会形成有效房间贡献;这类事件本身没有可补偿数据,跳过比失败重放更安全。
|
||
return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
|
||
}
|
||
occurred := time.UnixMilli(envelope.GetOccurredAtMs()).UTC()
|
||
if envelope.GetOccurredAtMs() <= 0 {
|
||
// 老事件缺 occurred_at 时使用消费时间兜底,只影响本地兼容,正常 outbox 事件仍以事实发生时间归属周期。
|
||
occurred = s.now().UTC()
|
||
}
|
||
start, end := WeekWindow(occurred)
|
||
// AppCode 以事件信封为准,防止消费者默认上下文把不同 App 的房间流水写进同一组配置和聚合表。
|
||
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
|
||
return s.repository.ConsumeRoomTurnoverGiftEvent(eventCtx, domain.RoomGiftEvent{
|
||
EventID: envelope.GetEventId(),
|
||
EventType: envelope.GetEventType(),
|
||
AppCode: envelope.GetAppCode(),
|
||
RoomID: strings.TrimSpace(envelope.GetRoomId()),
|
||
CoinSpent: gift.GetCoinSpent(),
|
||
OccurredAtMS: occurred.UnixMilli(),
|
||
}, start.UnixMilli(), end.UnixMilli(), s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) ListSettlements(ctx context.Context, query domain.SettlementQuery) ([]domain.Settlement, int64, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
query.Status = strings.TrimSpace(query.Status)
|
||
query.RoomID = strings.TrimSpace(query.RoomID)
|
||
if query.Page <= 0 {
|
||
query.Page = 1
|
||
}
|
||
if query.PageSize <= 0 {
|
||
query.PageSize = 20
|
||
}
|
||
if query.PageSize > 100 {
|
||
query.PageSize = 100
|
||
}
|
||
return s.repository.ListRoomTurnoverRewardSettlements(ctx, query)
|
||
}
|
||
|
||
// ProcessSettlementBatch 先为上一 UTC 周期补齐 settlement,再处理 pending 发币。
|
||
func (s *Service) ProcessSettlementBatch(ctx context.Context, batchSize int) (domain.BatchResult, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.BatchResult{}, err
|
||
}
|
||
if batchSize <= 0 {
|
||
batchSize = 100
|
||
}
|
||
config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx)
|
||
if err != nil {
|
||
return domain.BatchResult{}, err
|
||
}
|
||
if !exists || !config.Enabled {
|
||
// 活动未配置或关闭时不创建 settlement,也不触碰历史 pending;运营重新开启后再由 cron 正常补偿。
|
||
return domain.BatchResult{}, nil
|
||
}
|
||
// 只结算上一个完整 UTC 周期;当前周仍在持续接收 RoomGiftSent,不能提前冻结或清空。
|
||
periodStart, periodEnd := PreviousWeekWindow(s.now().UTC())
|
||
result := domain.BatchResult{}
|
||
progresses, err := s.repository.ListUnsettledRoomTurnoverRewardProgress(ctx, periodStart.UnixMilli(), batchSize)
|
||
if err != nil {
|
||
return domain.BatchResult{}, err
|
||
}
|
||
nowMS := s.now().UTC().UnixMilli()
|
||
for _, progress := range progresses {
|
||
// settlement 先固化当周流水和命中档位快照,后续运营改配置不会改变已经进入结算队列的周期结果。
|
||
settlement := s.settlementFromProgress(ctx, progress, periodEnd.UnixMilli(), activeTiers(config.Tiers), nowMS)
|
||
if settlement.TierID <= 0 {
|
||
// 有流水但未达档位也要落失败记录,后台可以解释为什么该房间本周期没有发奖。
|
||
settlement.Status = domain.SettlementStatusFailed
|
||
settlement.FailureReason = "tier_not_matched"
|
||
}
|
||
if settlement.TierID > 0 && settlement.OwnerUserID <= 0 {
|
||
// 房主归属以结算时 room-service 快照为准;activity-service 不复制房间 owner 状态,只在需要发奖时同步读取。
|
||
ownerID, ownerErr := s.fetchRoomOwner(ctx, progress.RoomID)
|
||
if ownerErr != nil || ownerID <= 0 {
|
||
settlement.Status = domain.SettlementStatusFailed
|
||
settlement.FailureReason = "room_owner_not_found"
|
||
} else {
|
||
settlement.OwnerUserID = ownerID
|
||
}
|
||
}
|
||
if _, created, err := s.repository.InsertRoomTurnoverRewardSettlement(ctx, settlement, nowMS); err != nil {
|
||
return result, err
|
||
} else if created {
|
||
// Insert 使用 room_id + period_start_ms 唯一键兜底;重复 cron 只会读回旧记录,不会重复认领同一周期。
|
||
result.ClaimedCount++
|
||
}
|
||
}
|
||
remaining := batchSize - int(result.ClaimedCount)
|
||
if remaining <= 0 {
|
||
remaining = batchSize
|
||
}
|
||
pending, err := s.repository.ListPendingRoomTurnoverRewardSettlements(ctx, remaining)
|
||
if err != nil {
|
||
return result, err
|
||
}
|
||
for _, settlement := range pending {
|
||
result.ProcessedCount++
|
||
if _, err := s.grantSettlement(ctx, settlement); err != nil {
|
||
// grantSettlement 已把失败原因写回 settlement;batch 继续处理后续房间,避免单房间阻塞整周结算。
|
||
result.FailureCount++
|
||
continue
|
||
}
|
||
result.SuccessCount++
|
||
}
|
||
result.HasMore = len(progresses) >= batchSize || len(pending) >= remaining
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) RetrySettlement(ctx context.Context, settlementID string) (domain.Settlement, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Settlement{}, err
|
||
}
|
||
settlementID = strings.TrimSpace(settlementID)
|
||
if settlementID == "" {
|
||
return domain.Settlement{}, xerr.New(xerr.InvalidArgument, "settlement_id is required")
|
||
}
|
||
settlement, exists, err := s.repository.GetRoomTurnoverRewardSettlement(ctx, settlementID)
|
||
if err != nil {
|
||
return domain.Settlement{}, err
|
||
}
|
||
if !exists {
|
||
return domain.Settlement{}, xerr.New(xerr.NotFound, "room turnover reward settlement not found")
|
||
}
|
||
if settlement.Status == domain.SettlementStatusGranted {
|
||
// 已发奖记录直接返回,后台重复点击 retry 不会再次调用钱包。
|
||
return settlement, nil
|
||
}
|
||
if settlement.TierID <= 0 {
|
||
// tier_not_matched 不是外部依赖失败,而是该房间当周没有达到任何档位;retry 不能把这类 0 档位记录重置成 granted。
|
||
return settlement, xerr.New(xerr.InvalidArgument, "room turnover reward settlement is not grantable")
|
||
}
|
||
ownerID := settlement.OwnerUserID
|
||
if ownerID <= 0 {
|
||
// 失败记录可能是当时没读到房主;retry 时重新取 room-service 快照,允许房间修复后继续发奖。
|
||
ownerID, err = s.fetchRoomOwner(ctx, settlement.RoomID)
|
||
if err != nil || ownerID <= 0 {
|
||
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, "room_owner_not_found", s.now().UTC().UnixMilli())
|
||
return domain.Settlement{}, xerr.New(xerr.NotFound, "room owner not found")
|
||
}
|
||
}
|
||
settlement, err = s.repository.ResetRoomTurnoverRewardSettlementForRetry(ctx, settlement.SettlementID, ownerID, s.now().UTC().UnixMilli())
|
||
if err != nil {
|
||
return domain.Settlement{}, err
|
||
}
|
||
return s.grantSettlement(ctx, settlement)
|
||
}
|
||
|
||
func (s *Service) settlementFromProgress(ctx context.Context, progress domain.Progress, periodEndMS int64, tiers []domain.Tier, nowMS int64) domain.Settlement {
|
||
tier, matched := MatchHighestTier(tiers, progress.CoinSpent)
|
||
settlement := domain.Settlement{
|
||
AppCode: appcode.FromContext(ctx),
|
||
RoomID: progress.RoomID,
|
||
PeriodStartMS: progress.PeriodStartMS,
|
||
PeriodEndMS: periodEndMS,
|
||
CoinSpent: progress.CoinSpent,
|
||
Status: domain.SettlementStatusPending,
|
||
CreatedAtMS: nowMS,
|
||
UpdatedAtMS: nowMS,
|
||
}
|
||
if matched {
|
||
// 结算记录保存档位快照;后续配置变更不反推历史奖励金额。
|
||
settlement.TierID = tier.TierID
|
||
settlement.TierCode = tier.TierCode
|
||
settlement.ThresholdCoinSpent = tier.ThresholdCoinSpent
|
||
settlement.RewardCoinAmount = tier.RewardCoinAmount
|
||
}
|
||
return settlement
|
||
}
|
||
|
||
func (s *Service) grantSettlement(ctx context.Context, settlement domain.Settlement) (domain.Settlement, error) {
|
||
if settlement.Status == domain.SettlementStatusGranted {
|
||
// 发奖入口保持幂等,避免 cron 和人工 retry 同时进来时重复入账。
|
||
return settlement, nil
|
||
}
|
||
if settlement.OwnerUserID <= 0 || settlement.RewardCoinAmount < 0 || settlement.WalletCommandID == "" {
|
||
err := xerr.New(xerr.InvalidArgument, "room turnover reward settlement is not grantable")
|
||
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||
return domain.Settlement{}, err
|
||
}
|
||
if settlement.RewardCoinAmount == 0 {
|
||
// 0 金币档位是合法配置,用于运营只记录达标但不发币的周期;钱包不接受 0 金额命令,所以这里直接收敛结算状态。
|
||
nowMS := s.now().UTC().UnixMilli()
|
||
return s.repository.MarkRoomTurnoverRewardSettlementGranted(ctx, settlement.SettlementID, "", nowMS)
|
||
}
|
||
if s.wallet == nil {
|
||
err := xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||
return domain.Settlement{}, err
|
||
}
|
||
// 钱包 command_id 使用 app + 周期 + room_id,保证同一房间同一周期无论 cron 重跑还是 retry,都只产生一笔账务交易。
|
||
resp, err := s.wallet.CreditRoomTurnoverReward(ctx, &walletv1.CreditRoomTurnoverRewardRequest{
|
||
CommandId: settlement.WalletCommandID,
|
||
AppCode: appcode.FromContext(ctx),
|
||
TargetUserId: settlement.OwnerUserID,
|
||
Amount: settlement.RewardCoinAmount,
|
||
SettlementId: settlement.SettlementID,
|
||
RoomId: settlement.RoomID,
|
||
PeriodStartMs: settlement.PeriodStartMS,
|
||
PeriodEndMs: settlement.PeriodEndMS,
|
||
CoinSpent: settlement.CoinSpent,
|
||
TierId: settlement.TierID,
|
||
TierCode: settlement.TierCode,
|
||
Reason: rewardReason,
|
||
})
|
||
if err != nil {
|
||
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||
return domain.Settlement{}, err
|
||
}
|
||
return s.repository.MarkRoomTurnoverRewardSettlementGranted(ctx, settlement.SettlementID, resp.GetTransactionId(), resp.GetGrantedAtMs())
|
||
}
|
||
|
||
func (s *Service) fetchRoomOwner(ctx context.Context, roomID string) (int64, error) {
|
||
if s.room == nil {
|
||
return 0, xerr.New(xerr.Unavailable, "room query client is not configured")
|
||
}
|
||
roomID = strings.TrimSpace(roomID)
|
||
if roomID == "" {
|
||
return 0, xerr.New(xerr.InvalidArgument, "room_id is required")
|
||
}
|
||
resp, err := s.room.AdminGetRoom(ctx, &roomv1.AdminGetRoomRequest{
|
||
Meta: &roomv1.RequestMeta{
|
||
// 这里不是用户请求链路,使用可读的内部 request_id 方便按房间追踪结算时的 owner 查询。
|
||
RequestId: "room-turnover-reward-owner:" + roomID,
|
||
AppCode: appcode.FromContext(ctx),
|
||
SentAtMs: s.now().UTC().UnixMilli(),
|
||
},
|
||
RoomId: roomID,
|
||
})
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return resp.GetRoom().GetOwnerUserId(), nil
|
||
}
|
||
|
||
func (s *Service) requireRepository() error {
|
||
if s == nil || s.repository == nil {
|
||
return xerr.New(xerr.Unavailable, "room turnover reward repository is not configured")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeTiers(tiers []domain.Tier) []domain.Tier {
|
||
items := make([]domain.Tier, 0, len(tiers))
|
||
for _, tier := range tiers {
|
||
status := strings.ToLower(strings.TrimSpace(tier.Status))
|
||
if status == "" {
|
||
// 后台表单可以省略 status;默认 active 保持配置体验简单,但 validate 仍会拦截非法状态。
|
||
status = domain.TierStatusActive
|
||
}
|
||
items = append(items, domain.Tier{
|
||
TierID: tier.TierID,
|
||
TierCode: strings.TrimSpace(tier.TierCode),
|
||
TierName: strings.TrimSpace(tier.TierName),
|
||
ThresholdCoinSpent: tier.ThresholdCoinSpent,
|
||
RewardCoinAmount: tier.RewardCoinAmount,
|
||
Status: status,
|
||
SortOrder: tier.SortOrder,
|
||
})
|
||
}
|
||
return sortTiersForDisplay(items)
|
||
}
|
||
|
||
func sortTiersForDisplay(tiers []domain.Tier) []domain.Tier {
|
||
items := append([]domain.Tier(nil), tiers...)
|
||
sort.SliceStable(items, func(i, j int) bool {
|
||
if items[i].SortOrder == items[j].SortOrder {
|
||
return items[i].ThresholdCoinSpent < items[j].ThresholdCoinSpent
|
||
}
|
||
return items[i].SortOrder < items[j].SortOrder
|
||
})
|
||
return items
|
||
}
|
||
|
||
func validateConfig(config domain.Config) error {
|
||
if config.UpdatedByAdminID <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
|
||
}
|
||
// tier_code 是后台配置稳定标识,阈值和奖励可以修改,但同一 App 内不能出现两个相同编码。
|
||
seen := make(map[string]struct{}, len(config.Tiers))
|
||
activeCount := 0
|
||
for _, tier := range config.Tiers {
|
||
if tier.TierCode == "" {
|
||
return xerr.New(xerr.InvalidArgument, "tier_code is required")
|
||
}
|
||
if _, exists := seen[tier.TierCode]; exists {
|
||
return xerr.New(xerr.InvalidArgument, "tier_code is duplicated")
|
||
}
|
||
seen[tier.TierCode] = struct{}{}
|
||
if tier.TierName == "" {
|
||
return xerr.New(xerr.InvalidArgument, "tier_name is required")
|
||
}
|
||
if tier.ThresholdCoinSpent <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "threshold_coin_spent must be greater than zero")
|
||
}
|
||
if tier.RewardCoinAmount < 0 {
|
||
return xerr.New(xerr.InvalidArgument, "reward_coin_amount must not be negative")
|
||
}
|
||
switch tier.Status {
|
||
case domain.TierStatusActive:
|
||
activeCount++
|
||
case domain.TierStatusInactive:
|
||
default:
|
||
return xerr.New(xerr.InvalidArgument, "tier status is invalid")
|
||
}
|
||
}
|
||
if config.Enabled && activeCount == 0 {
|
||
// 开启活动但没有有效档位会让 H5 显示可参与却永远无法发奖,直接在配置入口拒绝。
|
||
return xerr.New(xerr.InvalidArgument, "enabled config requires at least one active tier")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func activeTiers(tiers []domain.Tier) []domain.Tier {
|
||
out := make([]domain.Tier, 0, len(tiers))
|
||
for _, tier := range tiers {
|
||
if tier.Status == domain.TierStatusActive {
|
||
out = append(out, tier)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// MatchHighestTier 返回达到流水阈值的最高有效档。
|
||
func MatchHighestTier(tiers []domain.Tier, coinSpent int64) (domain.Tier, bool) {
|
||
var matched domain.Tier
|
||
found := false
|
||
for _, tier := range tiers {
|
||
if tier.Status != domain.TierStatusActive || tier.ThresholdCoinSpent <= 0 || tier.RewardCoinAmount < 0 {
|
||
continue
|
||
}
|
||
if coinSpent >= tier.ThresholdCoinSpent && (!found || tier.ThresholdCoinSpent > matched.ThresholdCoinSpent) {
|
||
matched = tier
|
||
found = true
|
||
}
|
||
}
|
||
return matched, found
|
||
}
|
||
|
||
// WeekWindow 返回给定时间所在 UTC 周一 00:00 到下一周一 00:00 的左闭右开窗口。
|
||
func WeekWindow(now time.Time) (time.Time, time.Time) {
|
||
utc := now.UTC()
|
||
dayStart := time.Date(utc.Year(), utc.Month(), 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 返回当前时间之前的完整 UTC 周期。
|
||
func PreviousWeekWindow(now time.Time) (time.Time, time.Time) {
|
||
start, _ := WeekWindow(now)
|
||
previousStart := start.AddDate(0, 0, -7)
|
||
return previousStart, start
|
||
}
|