740 lines
28 KiB
Go
740 lines
28 KiB
Go
package luckygift
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/logx"
|
||
"hyapp/pkg/tencentim"
|
||
"hyapp/pkg/xerr"
|
||
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
|
||
domain "hyapp/services/activity-service/internal/domain/luckygift"
|
||
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
|
||
|
||
"google.golang.org/grpc"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
)
|
||
|
||
// Repository 是幸运礼物唯一持久化边界;RTP、奖池、风控计数必须在同一个 MySQL 事务内修改。
|
||
type Repository interface {
|
||
GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error)
|
||
PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error)
|
||
ListLuckyGiftRuleConfigs(ctx context.Context) ([]domain.RuleConfig, error)
|
||
CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
|
||
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
|
||
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
|
||
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
|
||
GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error)
|
||
ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.DrawOutbox, error)
|
||
MarkLuckyGiftOutboxDelivered(ctx context.Context, event domain.DrawOutbox, nowMS int64) error
|
||
MarkLuckyGiftOutboxRetryable(ctx context.Context, event domain.DrawOutbox, retryCount int, nextRetryAtMS int64, lastErr string, nowMS int64) error
|
||
MarkLuckyGiftOutboxFailed(ctx context.Context, event domain.DrawOutbox, retryCount int, lastErr string, nowMS int64) error
|
||
RefreshLuckyGiftAdminStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error)
|
||
MarkLuckyGiftDrawGranted(ctx context.Context, appCode string, drawID string, walletTransactionID string, nowMS int64) error
|
||
MarkLuckyGiftDrawFailed(ctx context.Context, appCode string, drawID string, failureReason string, nowMS int64) error
|
||
// 批量抽奖只产生一条聚合 outbox;worker 处理后需要用 draw_ids 一次性收敛所有明细状态。
|
||
MarkLuckyGiftDrawsGranted(ctx context.Context, appCode string, drawIDs []string, walletTransactionID string, nowMS int64) error
|
||
MarkLuckyGiftDrawsFailed(ctx context.Context, appCode string, drawIDs []string, failureReason string, nowMS int64) error
|
||
}
|
||
|
||
// WalletClient 是幸运礼物返奖唯一账务依赖;实际余额变更通知继续由 wallet outbox -> notice-service 投递。
|
||
type WalletClient interface {
|
||
CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error)
|
||
}
|
||
|
||
// RoomPublisher 只负责投递房间群自定义消息,不拥有房间状态。
|
||
type RoomPublisher interface {
|
||
PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error
|
||
}
|
||
|
||
// RegionBroadcaster 只写 activity-service 自有的区域播报 outbox;真正发腾讯 IM 仍由 broadcast worker 补偿。
|
||
type RegionBroadcaster interface {
|
||
PublishRegionBroadcast(ctx context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error)
|
||
}
|
||
|
||
// Service 承载幸运礼物查询、抽奖命令和后台规则配置。
|
||
type Service struct {
|
||
repository Repository
|
||
wallet WalletClient
|
||
publisher RoomPublisher
|
||
broadcaster RegionBroadcaster
|
||
now func() time.Time
|
||
}
|
||
|
||
type Option func(*Service)
|
||
|
||
func WithWallet(client WalletClient) Option {
|
||
return func(s *Service) {
|
||
s.wallet = client
|
||
}
|
||
}
|
||
|
||
func WithRoomPublisher(publisher RoomPublisher) Option {
|
||
return func(s *Service) {
|
||
s.publisher = publisher
|
||
}
|
||
}
|
||
|
||
func WithRegionBroadcaster(broadcaster RegionBroadcaster) Option {
|
||
return func(s *Service) {
|
||
s.broadcaster = broadcaster
|
||
}
|
||
}
|
||
|
||
func New(repository Repository, options ...Option) *Service {
|
||
service := &Service{repository: repository, now: time.Now}
|
||
for _, option := range options {
|
||
if option != nil {
|
||
option(service)
|
||
}
|
||
}
|
||
return service
|
||
}
|
||
|
||
func (s *Service) SetClock(now func() time.Time) {
|
||
if now != nil {
|
||
s.now = now
|
||
}
|
||
}
|
||
|
||
func (s *Service) Check(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.CheckResult{}, err
|
||
}
|
||
// Check 是送礼前拦截,不产生抽奖事实;这里只归一化入参,最终启停仍以仓储中的当前规则为准。
|
||
cmd.PoolID = normalizePoolID(cmd.PoolID)
|
||
cmd.GiftID = strings.TrimSpace(cmd.GiftID)
|
||
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
|
||
if cmd.GiftID == "" || cmd.UserID <= 0 || cmd.RoomID == "" {
|
||
return domain.CheckResult{}, xerr.New(xerr.InvalidArgument, "lucky gift check command is incomplete")
|
||
}
|
||
return s.repository.CheckLuckyGift(ctx, cmd)
|
||
}
|
||
|
||
func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.DrawResult, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.DrawResult{}, err
|
||
}
|
||
// Draw 只接受扣费成功后的服务端事实;客户端不能提交价格、倍率、奖励或风控绕过字段。
|
||
cmd.CommandID = strings.TrimSpace(cmd.CommandID)
|
||
cmd.PoolID = normalizePoolID(cmd.PoolID)
|
||
cmd.GiftID = strings.TrimSpace(cmd.GiftID)
|
||
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
|
||
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
|
||
cmd.AnchorID = strings.TrimSpace(cmd.AnchorID)
|
||
if cmd.CommandID == "" || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
|
||
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
|
||
}
|
||
if cmd.TargetUserID < 0 || cmd.GiftCount <= 0 {
|
||
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
|
||
}
|
||
if cmd.PaidAtMS <= 0 {
|
||
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免使用本地时区切风控窗口。
|
||
cmd.PaidAtMS = s.now().UTC().UnixMilli()
|
||
}
|
||
result, err := s.repository.ExecuteLuckyGiftDraw(ctx, cmd, s.now().UTC().UnixMilli())
|
||
if err != nil {
|
||
return domain.DrawResult{}, err
|
||
}
|
||
return s.creditLuckyGiftRewardFastPath(ctx, cmd, result), nil
|
||
}
|
||
|
||
type WorkerOptions struct {
|
||
WorkerID string
|
||
PollInterval time.Duration
|
||
BatchSize int
|
||
Concurrency int
|
||
LockTTL time.Duration
|
||
MaxRetry int
|
||
PublishTimeout time.Duration
|
||
StatsInterval time.Duration
|
||
StatsBatchSize int
|
||
}
|
||
|
||
func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
|
||
options = normalizeWorkerOptions(options)
|
||
ticker := time.NewTicker(options.PollInterval)
|
||
defer ticker.Stop()
|
||
nextStatsRefresh := time.Time{}
|
||
for {
|
||
if _, err := s.ProcessPendingDrawOutbox(ctx, options); err != nil && ctx.Err() == nil {
|
||
// 批处理失败不删除 outbox;锁过期后下一轮或其他实例会继续补偿。
|
||
logx.Error(ctx, "lucky_gift_outbox_batch_failed", err, slog.String("worker_id", options.WorkerID))
|
||
}
|
||
now := s.now().UTC()
|
||
if options.StatsInterval > 0 && (nextStatsRefresh.IsZero() || !now.Before(nextStatsRefresh)) {
|
||
if _, err := s.repository.RefreshLuckyGiftAdminStatsSnapshots(ctx, now.UnixMilli(), options.StatsBatchSize); err != nil && ctx.Err() == nil {
|
||
logx.Error(ctx, "lucky_gift_admin_stats_refresh_failed", err, slog.String("worker_id", options.WorkerID))
|
||
}
|
||
nextStatsRefresh = now.Add(options.StatsInterval)
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Service) ProcessPendingDrawOutbox(ctx context.Context, options WorkerOptions) (int, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return 0, err
|
||
}
|
||
options = normalizeWorkerOptions(options)
|
||
// outbox 只承载钱包返奖结算,避免多实例同时给同一 draw 返奖。
|
||
events, err := s.repository.ClaimPendingLuckyGiftOutbox(ctx, options.WorkerID, s.now().UTC().UnixMilli(), options.LockTTL, options.BatchSize)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if len(events) == 0 {
|
||
return 0, nil
|
||
}
|
||
workerCount := options.Concurrency
|
||
if workerCount > len(events) {
|
||
workerCount = len(events)
|
||
}
|
||
jobs := make(chan domain.DrawOutbox)
|
||
errs := make(chan error, len(events))
|
||
var wg sync.WaitGroup
|
||
for index := 0; index < workerCount; index++ {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
for event := range jobs {
|
||
if err := s.processDrawOutbox(ctx, event, options); err != nil {
|
||
errs <- err
|
||
continue
|
||
}
|
||
errs <- nil
|
||
}
|
||
}()
|
||
}
|
||
for _, event := range events {
|
||
select {
|
||
case <-ctx.Done():
|
||
close(jobs)
|
||
wg.Wait()
|
||
close(errs)
|
||
return 0, ctx.Err()
|
||
case jobs <- event:
|
||
}
|
||
}
|
||
close(jobs)
|
||
wg.Wait()
|
||
close(errs)
|
||
processed := 0
|
||
var firstErr error
|
||
for err := range errs {
|
||
if err != nil {
|
||
if firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
continue
|
||
}
|
||
processed++
|
||
}
|
||
return processed, firstErr
|
||
}
|
||
|
||
func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox, options WorkerOptions) error {
|
||
payload, err := decodeDrawOutboxPayload(event.PayloadJSON)
|
||
if err != nil {
|
||
// payload 不可解析说明抽奖事实旁路已经损坏,重试也无法恢复,直接走失败收敛。
|
||
return s.markOutboxFailed(ctx, event, options, err, false)
|
||
}
|
||
eventCtx := appcode.WithContext(ctx, event.AppCode)
|
||
switch event.EventType {
|
||
case domain.EventTypeLuckyGiftRewardSettlement, domain.EventTypeLuckyGiftDrawn, "":
|
||
return s.processRewardSettlementOutbox(eventCtx, event, payload, options)
|
||
default:
|
||
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("unsupported lucky gift outbox event type: %s", event.EventType), false)
|
||
}
|
||
}
|
||
|
||
func (s *Service) processRewardSettlementOutbox(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) error {
|
||
if _, _, err := s.settleLuckyGiftReward(ctx, event, payload, options); err != nil {
|
||
return err
|
||
}
|
||
return s.repository.MarkLuckyGiftOutboxDelivered(ctx, event, s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) (bool, bool, error) {
|
||
walletTransactionID := ""
|
||
credited := false
|
||
alreadyGranted := false
|
||
if payload.EffectiveRewardCoins > 0 {
|
||
state, err := s.repository.GetLuckyGiftDrawRewardState(ctx, event.AppCode, payload.DrawIDs)
|
||
if err != nil {
|
||
return false, false, s.markOutboxFailed(ctx, event, options, err, false)
|
||
}
|
||
alreadyGranted = state.AllGranted
|
||
if alreadyGranted {
|
||
walletTransactionID = state.WalletTransactionID
|
||
credited = true
|
||
} else {
|
||
receipt, err := s.creditLuckyGiftReward(ctx, event.AppCode, payload)
|
||
if err != nil {
|
||
return false, false, s.markOutboxFailed(ctx, event, options, err, false)
|
||
}
|
||
walletTransactionID = receipt.WalletTransactionID
|
||
credited = true
|
||
}
|
||
}
|
||
if !credited {
|
||
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil {
|
||
return false, false, err
|
||
}
|
||
return false, false, nil
|
||
}
|
||
if !alreadyGranted {
|
||
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil {
|
||
return credited, alreadyGranted, err
|
||
}
|
||
}
|
||
return credited, alreadyGranted, nil
|
||
}
|
||
|
||
type luckyGiftRewardReceipt struct {
|
||
WalletTransactionID string
|
||
CoinBalanceAfter int64
|
||
}
|
||
|
||
func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
|
||
if result.EffectiveRewardCoins <= 0 || result.RewardStatus == domain.StatusGranted {
|
||
return result
|
||
}
|
||
payload := luckyGiftDrawnPayload{
|
||
AppCode: appcode.FromContext(ctx),
|
||
DrawID: result.DrawID,
|
||
DrawIDs: luckyDrawResultIDs(result),
|
||
CommandID: cmd.CommandID,
|
||
PoolID: result.PoolID,
|
||
RoomID: cmd.RoomID,
|
||
GiftID: cmd.GiftID,
|
||
GiftCount: cmd.GiftCount,
|
||
UserID: cmd.UserID,
|
||
SenderUserID: cmd.UserID,
|
||
TargetUserID: cmd.TargetUserID,
|
||
VisibleRegionID: cmd.VisibleRegionID,
|
||
CoinSpent: cmd.CoinSpent,
|
||
RuleVersion: result.RuleVersion,
|
||
ExperiencePool: result.ExperiencePool,
|
||
SelectedTierID: result.SelectedTierID,
|
||
MultiplierPPM: result.MultiplierPPM,
|
||
BaseRewardCoins: result.BaseRewardCoins,
|
||
RoomAtmosphereRewardCoins: result.RoomAtmosphereRewardCoins,
|
||
ActivitySubsidyCoins: result.ActivitySubsidyCoins,
|
||
EffectiveRewardCoins: result.EffectiveRewardCoins,
|
||
StageFeedback: result.StageFeedback,
|
||
HighMultiplier: result.HighMultiplier,
|
||
CreatedAtMS: result.CreatedAtMS,
|
||
}
|
||
receipt, err := s.creditLuckyGiftReward(ctx, payload.AppCode, payload)
|
||
if err != nil {
|
||
logx.Error(ctx, "lucky_gift_reward_fast_path_failed", err,
|
||
slog.String("draw_id", result.DrawID),
|
||
slog.String("command_id", result.CommandID),
|
||
slog.Int64("reward_coins", result.EffectiveRewardCoins),
|
||
)
|
||
return result
|
||
}
|
||
result.RewardStatus = domain.StatusGranted
|
||
result.WalletTransactionID = receipt.WalletTransactionID
|
||
result.CoinBalanceAfter = receipt.CoinBalanceAfter
|
||
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, payload.AppCode, payload.DrawIDs, receipt.WalletTransactionID); err != nil {
|
||
logx.Error(ctx, "lucky_gift_reward_fast_path_mark_granted_failed", err,
|
||
slog.String("draw_id", result.DrawID),
|
||
slog.String("command_id", result.CommandID),
|
||
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
|
||
)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (s *Service) markLuckyGiftDrawsGrantedAfterWallet(ctx context.Context, appCode string, drawIDs []string, walletTransactionID string) error {
|
||
baseCtx := context.WithoutCancel(ctx)
|
||
var lastErr error
|
||
for attempt := 0; attempt < 3; attempt++ {
|
||
markCtx, cancel := context.WithTimeout(appcode.WithContext(baseCtx, appCode), 5*time.Second)
|
||
err := s.repository.MarkLuckyGiftDrawsGranted(markCtx, appCode, drawIDs, walletTransactionID, s.now().UTC().UnixMilli())
|
||
cancel()
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
lastErr = err
|
||
if !isRetryableLuckyGrantMarkError(err) {
|
||
return err
|
||
}
|
||
time.Sleep(time.Duration(attempt+1) * 50 * time.Millisecond)
|
||
}
|
||
return lastErr
|
||
}
|
||
|
||
func (s *Service) creditLuckyGiftReward(ctx context.Context, appCode string, payload luckyGiftDrawnPayload) (luckyGiftRewardReceipt, error) {
|
||
if s.wallet == nil {
|
||
return luckyGiftRewardReceipt{}, fmt.Errorf("lucky gift wallet client is not configured")
|
||
}
|
||
resp, err := s.wallet.CreditLuckyGiftReward(appcode.WithContext(ctx, appCode), &walletv1.CreditLuckyGiftRewardRequest{
|
||
CommandId: "lucky_reward:" + payload.DrawID,
|
||
AppCode: appCode,
|
||
TargetUserId: payload.UserID,
|
||
Amount: payload.EffectiveRewardCoins,
|
||
DrawId: payload.DrawID,
|
||
RoomId: payload.RoomID,
|
||
GiftId: payload.GiftID,
|
||
PoolId: payload.PoolID,
|
||
Reason: "lucky_gift_reward",
|
||
VisibleRegionId: payload.VisibleRegionID,
|
||
})
|
||
if err != nil {
|
||
return luckyGiftRewardReceipt{}, err
|
||
}
|
||
return luckyGiftRewardReceipt{
|
||
WalletTransactionID: resp.GetTransactionId(),
|
||
CoinBalanceAfter: resp.GetBalance().GetAvailableAmount(),
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDrawnPayload, options WorkerOptions) error {
|
||
body, err := json.Marshal(luckyGiftRoomMessagePayload(payload))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 房间 IM 只是表现层事实同步,不参与钱包结算和 reward_status 收敛。
|
||
publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout)
|
||
defer cancel()
|
||
return s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{
|
||
GroupID: payload.RoomID,
|
||
EventID: payload.EventID,
|
||
Desc: "lucky_gift_drawn",
|
||
Ext: "room_system_message",
|
||
PayloadJSON: body,
|
||
})
|
||
}
|
||
|
||
func (s *Service) publishLuckyGiftRegionBroadcast(ctx context.Context, payload luckyGiftDrawnPayload) error {
|
||
if payload.VisibleRegionID <= 0 {
|
||
return nil
|
||
}
|
||
body, err := json.Marshal(luckyGiftRegionBroadcastPayload(payload, s.now().UTC().UnixMilli()))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = s.broadcaster.PublishRegionBroadcast(ctx, broadcastservice.PublishInput{
|
||
EventID: "lucky_gift_big_win:" + payload.DrawID,
|
||
BroadcastType: broadcastdomain.TypeLuckyGiftBigWin,
|
||
RegionID: payload.VisibleRegionID,
|
||
PayloadJSON: string(body),
|
||
})
|
||
return err
|
||
}
|
||
|
||
func (s *Service) markOutboxFailed(ctx context.Context, event domain.DrawOutbox, options WorkerOptions, cause error, alreadyCredited bool) error {
|
||
nowMS := s.now().UTC().UnixMilli()
|
||
nextRetryCount := event.RetryCount + 1
|
||
if nextRetryCount >= options.MaxRetry {
|
||
if !alreadyCredited {
|
||
// 未返奖的 draw 可以整批标记 failed;钱包已返奖时不能覆盖账务事实。
|
||
_ = s.repository.MarkLuckyGiftDrawsFailed(ctx, event.AppCode, drawIDsFromPayload(event.PayloadJSON), cause.Error(), nowMS)
|
||
}
|
||
return s.repository.MarkLuckyGiftOutboxFailed(ctx, event, nextRetryCount, cause.Error(), nowMS)
|
||
}
|
||
// 指数退避降低故障期间对钱包的压力,同时保留 pending 状态给客户端展示“处理中”。
|
||
nextRetryAtMS := nowMS + luckyGiftBackoff(nextRetryCount).Milliseconds()
|
||
return s.repository.MarkLuckyGiftOutboxRetryable(ctx, event, nextRetryCount, nextRetryAtMS, cause.Error(), nowMS)
|
||
}
|
||
|
||
func (s *Service) GetConfig(ctx context.Context, poolID string) (domain.RuleConfig, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.RuleConfig{}, err
|
||
}
|
||
poolID = normalizePoolID(poolID)
|
||
config, exists, err := s.repository.GetLuckyGiftRuleConfig(ctx, poolID)
|
||
if err != nil {
|
||
return domain.RuleConfig{}, err
|
||
}
|
||
if !exists {
|
||
// 未配置时返回 disabled v2 草稿,后台可以直接编辑发布;运行抽奖不会读取这个草稿。
|
||
return DefaultRuleConfig(appcode.FromContext(ctx), poolID), nil
|
||
}
|
||
return config, nil
|
||
}
|
||
|
||
func (s *Service) UpsertConfig(ctx context.Context, config domain.RuleConfig) (domain.RuleConfig, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.RuleConfig{}, err
|
||
}
|
||
config.AppCode = appcode.FromContext(ctx)
|
||
config.PoolID = normalizePoolID(config.PoolID)
|
||
config = normalizeRuleConfig(config)
|
||
if err := validateRuleConfig(config); err != nil {
|
||
return domain.RuleConfig{}, err
|
||
}
|
||
return s.repository.PublishLuckyGiftRuleConfig(ctx, config, s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) ListConfigs(ctx context.Context) ([]domain.RuleConfig, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.repository.ListLuckyGiftRuleConfigs(ctx)
|
||
}
|
||
|
||
func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
query = normalizeDrawQuery(query)
|
||
if query.Page <= 0 {
|
||
query.Page = 1
|
||
}
|
||
if query.PageSize <= 0 {
|
||
query.PageSize = 20
|
||
}
|
||
if query.PageSize > 100 {
|
||
query.PageSize = 100
|
||
}
|
||
return s.repository.ListLuckyGiftDraws(ctx, query)
|
||
}
|
||
|
||
func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.DrawSummary{}, err
|
||
}
|
||
return s.repository.GetLuckyGiftDrawSummary(ctx, normalizeDrawQuery(query))
|
||
}
|
||
|
||
func (s *Service) requireRepository() error {
|
||
if s == nil || s.repository == nil {
|
||
return xerr.New(xerr.Unavailable, "lucky gift repository is not configured")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery {
|
||
query.PoolID = normalizePoolID(query.PoolID)
|
||
query.GiftID = strings.TrimSpace(query.GiftID)
|
||
query.RoomID = strings.TrimSpace(query.RoomID)
|
||
query.Status = strings.TrimSpace(query.Status)
|
||
return query
|
||
}
|
||
|
||
type luckyGiftDrawnPayload struct {
|
||
EventType string `json:"event_type"`
|
||
EventID string `json:"event_id"`
|
||
AppCode string `json:"app_code"`
|
||
DrawID string `json:"draw_id"`
|
||
// DrawIDs 是聚合发放的明细集合;老 outbox 没有该字段时 decode 会回填为单个 DrawID。
|
||
DrawIDs []string `json:"draw_ids,omitempty"`
|
||
CommandID string `json:"command_id"`
|
||
PoolID string `json:"pool_id"`
|
||
RoomID string `json:"room_id"`
|
||
GiftID string `json:"gift_id"`
|
||
GiftCount int32 `json:"gift_count"`
|
||
UserID int64 `json:"user_id"`
|
||
SenderUserID int64 `json:"sender_user_id"`
|
||
TargetUserID int64 `json:"target_user_id"`
|
||
VisibleRegionID int64 `json:"visible_region_id"`
|
||
CoinSpent int64 `json:"coin_spent"`
|
||
RuleVersion int64 `json:"rule_version"`
|
||
ExperiencePool string `json:"experience_pool"`
|
||
SelectedTierID string `json:"selected_tier_id"`
|
||
MultiplierPPM int64 `json:"multiplier_ppm"`
|
||
BaseRewardCoins int64 `json:"base_reward_coins"`
|
||
RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"`
|
||
ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"`
|
||
EffectiveRewardCoins int64 `json:"effective_reward_coins"`
|
||
StageFeedback bool `json:"stage_feedback"`
|
||
HighMultiplier bool `json:"high_multiplier"`
|
||
CreatedAtMS int64 `json:"created_at_ms"`
|
||
}
|
||
|
||
func shouldPublishLuckyGiftRegionBroadcast(payload luckyGiftDrawnPayload) bool {
|
||
// 当前产品策略先把 1 倍作为默认区域飘屏阈值;后续接后台配置时只替换这里的阈值来源。
|
||
return payload.VisibleRegionID > 0 && payload.EffectiveRewardCoins > 0 && payload.MultiplierPPM >= 1_000_000
|
||
}
|
||
|
||
func luckyGiftRoomMessagePayload(payload luckyGiftDrawnPayload) map[string]any {
|
||
return map[string]any{
|
||
"event_type": "lucky_gift_drawn",
|
||
"event_id": payload.EventID,
|
||
"app_code": payload.AppCode,
|
||
"draw_id": payload.DrawID,
|
||
"command_id": payload.CommandID,
|
||
"pool_id": payload.PoolID,
|
||
"room_id": payload.RoomID,
|
||
"gift_id": payload.GiftID,
|
||
"gift_count": payload.GiftCount,
|
||
"user_id": payload.UserID,
|
||
"sender_user_id": payload.SenderUserID,
|
||
"target_user_id": payload.TargetUserID,
|
||
"visible_region_id": payload.VisibleRegionID,
|
||
"coin_spent": payload.CoinSpent,
|
||
"rule_version": payload.RuleVersion,
|
||
"experience_pool": payload.ExperiencePool,
|
||
"selected_tier_id": payload.SelectedTierID,
|
||
"multiplier_ppm": payload.MultiplierPPM,
|
||
"base_reward_coins": payload.BaseRewardCoins,
|
||
"room_atmosphere_reward_coins": payload.RoomAtmosphereRewardCoins,
|
||
"activity_subsidy_coins": payload.ActivitySubsidyCoins,
|
||
"effective_reward_coins": payload.EffectiveRewardCoins,
|
||
"stage_feedback": payload.StageFeedback,
|
||
"high_multiplier": payload.HighMultiplier,
|
||
"created_at_ms": payload.CreatedAtMS,
|
||
}
|
||
}
|
||
|
||
func luckyGiftRegionBroadcastPayload(payload luckyGiftDrawnPayload, sentAtMS int64) map[string]any {
|
||
return map[string]any{
|
||
"event_type": "lucky_gift_drawn",
|
||
"event_id": "lucky_gift_big_win:" + payload.DrawID,
|
||
"broadcast_type": broadcastdomain.TypeLuckyGiftBigWin,
|
||
"scope": broadcastdomain.ScopeRegion,
|
||
"app_code": payload.AppCode,
|
||
"region_id": payload.VisibleRegionID,
|
||
"visible_region_id": payload.VisibleRegionID,
|
||
"draw_id": payload.DrawID,
|
||
"command_id": payload.CommandID,
|
||
"pool_id": payload.PoolID,
|
||
"room_id": payload.RoomID,
|
||
"gift_id": payload.GiftID,
|
||
"gift_count": payload.GiftCount,
|
||
"user_id": payload.UserID,
|
||
"sender_user_id": payload.SenderUserID,
|
||
"target_user_id": payload.TargetUserID,
|
||
"coin_spent": payload.CoinSpent,
|
||
"rule_version": payload.RuleVersion,
|
||
"experience_pool": payload.ExperiencePool,
|
||
"selected_tier_id": payload.SelectedTierID,
|
||
"multiplier_ppm": payload.MultiplierPPM,
|
||
"base_reward_coins": payload.BaseRewardCoins,
|
||
"room_atmosphere_reward_coins": payload.RoomAtmosphereRewardCoins,
|
||
"activity_subsidy_coins": payload.ActivitySubsidyCoins,
|
||
"effective_reward_coins": payload.EffectiveRewardCoins,
|
||
"stage_feedback": payload.StageFeedback,
|
||
"high_multiplier": payload.HighMultiplier,
|
||
"created_at_ms": payload.CreatedAtMS,
|
||
"sent_at_ms": sentAtMS,
|
||
"action": map[string]any{
|
||
"type": "enter_room",
|
||
"room_id": payload.RoomID,
|
||
},
|
||
}
|
||
}
|
||
|
||
func decodeDrawOutboxPayload(payloadJSON string) (luckyGiftDrawnPayload, error) {
|
||
var payload luckyGiftDrawnPayload
|
||
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
|
||
return luckyGiftDrawnPayload{}, fmt.Errorf("invalid lucky gift outbox payload: %w", err)
|
||
}
|
||
// 兼容早期 payload 缺省字段,避免历史 pending outbox 因新增字段永久失败。
|
||
if payload.EventType == "" {
|
||
payload.EventType = "lucky_gift_drawn"
|
||
}
|
||
// 兼容两类 outbox:老单抽 payload 只有 draw_id,新批量 payload 同时有聚合 draw_id 和 draw_ids。
|
||
if payload.DrawID == "" && len(payload.DrawIDs) > 0 {
|
||
payload.DrawID = payload.DrawIDs[0]
|
||
}
|
||
if len(payload.DrawIDs) == 0 && payload.DrawID != "" {
|
||
payload.DrawIDs = []string{payload.DrawID}
|
||
}
|
||
if payload.EventID == "" && payload.DrawID != "" {
|
||
payload.EventID = "lucky_gift_drawn:" + payload.DrawID
|
||
}
|
||
if payload.SenderUserID == 0 {
|
||
payload.SenderUserID = payload.UserID
|
||
}
|
||
// 这些字段是返奖、房间 IM 和幂等去重的最小闭环,缺任何一个都不能安全补偿。
|
||
if payload.DrawID == "" || payload.EventID == "" || payload.RoomID == "" || payload.GiftID == "" || payload.UserID <= 0 {
|
||
return luckyGiftDrawnPayload{}, fmt.Errorf("lucky gift outbox payload is incomplete")
|
||
}
|
||
return payload, nil
|
||
}
|
||
|
||
// drawIDsFromPayload 只用于失败收敛;解析失败时返回 nil,让仓储层保持 no-op,避免误更新未知 draw。
|
||
func drawIDsFromPayload(payloadJSON string) []string {
|
||
payload, err := decodeDrawOutboxPayload(payloadJSON)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return payload.DrawIDs
|
||
}
|
||
|
||
func luckyDrawResultIDs(result domain.DrawResult) []string {
|
||
if len(result.DrawIDs) > 0 {
|
||
return append([]string(nil), result.DrawIDs...)
|
||
}
|
||
if strings.TrimSpace(result.DrawID) == "" {
|
||
return nil
|
||
}
|
||
return []string{result.DrawID}
|
||
}
|
||
|
||
func normalizeWorkerOptions(options WorkerOptions) WorkerOptions {
|
||
options.WorkerID = strings.TrimSpace(options.WorkerID)
|
||
if options.WorkerID == "" {
|
||
options.WorkerID = "lucky-gift-worker"
|
||
}
|
||
if options.PollInterval <= 0 {
|
||
options.PollInterval = time.Second
|
||
}
|
||
if options.BatchSize <= 0 {
|
||
options.BatchSize = 100
|
||
}
|
||
if options.BatchSize > 500 {
|
||
options.BatchSize = 500
|
||
}
|
||
if options.Concurrency <= 0 {
|
||
options.Concurrency = 4
|
||
}
|
||
if options.Concurrency > options.BatchSize {
|
||
options.Concurrency = options.BatchSize
|
||
}
|
||
if options.LockTTL <= 0 {
|
||
options.LockTTL = 30 * time.Second
|
||
}
|
||
if options.MaxRetry <= 0 {
|
||
options.MaxRetry = 8
|
||
}
|
||
if options.PublishTimeout <= 0 {
|
||
options.PublishTimeout = 5 * time.Second
|
||
}
|
||
if options.StatsInterval <= 0 {
|
||
options.StatsInterval = 10 * time.Minute
|
||
}
|
||
if options.StatsBatchSize <= 0 {
|
||
options.StatsBatchSize = 5000
|
||
}
|
||
if options.StatsBatchSize > 50000 {
|
||
options.StatsBatchSize = 50000
|
||
}
|
||
return options
|
||
}
|
||
|
||
func luckyGiftBackoff(retryCount int) time.Duration {
|
||
if retryCount < 1 {
|
||
retryCount = 1
|
||
}
|
||
if retryCount > 6 {
|
||
retryCount = 6
|
||
}
|
||
return time.Duration(1<<retryCount) * time.Second
|
||
}
|
||
|
||
func isRetryableLuckyGrantMarkError(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
message := err.Error()
|
||
return strings.Contains(message, "Deadlock") ||
|
||
strings.Contains(message, "1213") ||
|
||
strings.Contains(message, "40001") ||
|
||
strings.Contains(message, "Lock wait timeout")
|
||
}
|