979 lines
38 KiB
Go
979 lines
38 KiB
Go
package luckygift
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"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)
|
||
ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []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)
|
||
RefreshLuckyGiftDatabiStatsSnapshots(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-service 仍负责持久 outbox,activity 只做成功落库后的低延迟余额 IM。
|
||
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
|
||
}
|
||
|
||
// UserPublisher 负责把钱包余额变化私有消息发给中奖用户;失败不能回滚已经成功的钱包入账。
|
||
type UserPublisher interface {
|
||
PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
|
||
}
|
||
|
||
// RegionBroadcaster 只写 activity-service 自有的区域播报 outbox;真正发腾讯 IM 仍由 broadcast worker 补偿。
|
||
type RegionBroadcaster interface {
|
||
PublishRegionBroadcast(ctx context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error)
|
||
}
|
||
|
||
const luckyGiftRegionBroadcastMinMultiplierPPM = 5_000_000
|
||
|
||
// Service 承载幸运礼物查询、抽奖命令和后台规则配置。
|
||
type Service struct {
|
||
repository Repository
|
||
wallet WalletClient
|
||
publisher RoomPublisher
|
||
userPub UserPublisher
|
||
broadcaster RegionBroadcaster
|
||
senderProfileSource broadcastservice.SenderProfileSource
|
||
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 WithUserPublisher(publisher UserPublisher) Option {
|
||
return func(s *Service) {
|
||
s.userPub = publisher
|
||
}
|
||
}
|
||
|
||
func WithRegionBroadcaster(broadcaster RegionBroadcaster) Option {
|
||
return func(s *Service) {
|
||
s.broadcaster = broadcaster
|
||
}
|
||
}
|
||
|
||
func WithSenderProfileSource(source broadcastservice.SenderProfileSource) Option {
|
||
return func(s *Service) {
|
||
s.senderProfileSource = source
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
normalized, err := s.normalizeDrawCommand(cmd)
|
||
if err != nil {
|
||
return domain.DrawResult{}, err
|
||
}
|
||
result, err := s.repository.ExecuteLuckyGiftDraw(ctx, normalized, s.now().UTC().UnixMilli())
|
||
if err != nil {
|
||
return domain.DrawResult{}, err
|
||
}
|
||
return s.creditLuckyGiftRewardFastPath(ctx, normalized, result), nil
|
||
}
|
||
|
||
func (s *Service) DrawBatch(ctx context.Context, cmds []domain.DrawCommand) ([]domain.DrawResult, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return nil, err
|
||
}
|
||
if len(cmds) == 0 {
|
||
return nil, xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required")
|
||
}
|
||
normalized := make([]domain.DrawCommand, 0, len(cmds))
|
||
for _, cmd := range cmds {
|
||
item, err := s.normalizeDrawCommand(cmd)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
normalized = append(normalized, item)
|
||
}
|
||
// 批量送礼的返奖不能阻塞 room-service 主链路;repository 只落 draw fact 和 settlement outbox,worker 后续负责 wallet/IM。
|
||
return s.repository.ExecuteLuckyGiftDrawBatch(ctx, normalized, s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawCommand, error) {
|
||
// 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.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
|
||
}
|
||
if cmd.TargetUserID < 0 || cmd.GiftCount <= 0 {
|
||
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
|
||
}
|
||
if cmd.PaidAtMS <= 0 {
|
||
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免使用本地时区切风控窗口。
|
||
cmd.PaidAtMS = s.now().UTC().UnixMilli()
|
||
}
|
||
return cmd, 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))
|
||
}
|
||
if _, err := s.repository.RefreshLuckyGiftDatabiStatsSnapshots(ctx, now.UnixMilli(), options.StatsBatchSize); err != nil && ctx.Err() == nil {
|
||
logx.Error(ctx, "lucky_gift_databi_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 {
|
||
credited, alreadyGranted, receipt, err := s.settleLuckyGiftReward(ctx, event, payload, options)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if credited && !alreadyGranted {
|
||
// 钱包入账和 activity draw 状态都已经落库;这里的 C2C 只是降低客户端余额刷新延迟,失败时仍由 wallet outbox -> notice-service 补偿。
|
||
if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, options.PublishTimeout); err != nil {
|
||
logx.Error(ctx, "lucky_gift_wallet_balance_notice_failed", err,
|
||
slog.String("draw_id", payload.DrawID),
|
||
slog.String("command_id", payload.CommandID),
|
||
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
|
||
)
|
||
}
|
||
}
|
||
if credited && payload.EffectiveRewardCoins > 0 {
|
||
displayPayload := s.enrichLuckyGiftSenderProfile(ctx, payload)
|
||
// 房间中奖 IM 必须在钱包返奖落库之后发;发送方 HTTP 本地表现不能替代房间内其他用户的实时展示。
|
||
// 这里即使 draw 已经 granted 也会补发一次:fast path 可能先完成账务,随后 outbox worker 才负责可靠补房间展示。
|
||
if err := s.publishLuckyGiftDrawn(ctx, displayPayload, options); err != nil {
|
||
logx.Error(ctx, "lucky_gift_room_drawn_notice_failed", err,
|
||
slog.String("draw_id", payload.DrawID),
|
||
slog.String("command_id", payload.CommandID),
|
||
slog.String("event_id", payload.EventID),
|
||
)
|
||
}
|
||
if shouldPublishLuckyGiftRegionBroadcast(displayPayload) {
|
||
// 区域播报和房间 IM 都是表现层副作用,失败不回滚已经成功的返奖账务。
|
||
if err := s.publishLuckyGiftRegionBroadcast(ctx, displayPayload); err != nil {
|
||
logx.Error(ctx, "lucky_gift_region_broadcast_failed", err,
|
||
slog.String("draw_id", payload.DrawID),
|
||
slog.String("command_id", payload.CommandID),
|
||
slog.Int64("visible_region_id", payload.VisibleRegionID),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
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, luckyGiftRewardReceipt, error) {
|
||
walletTransactionID := ""
|
||
credited := false
|
||
alreadyGranted := false
|
||
receipt := luckyGiftRewardReceipt{}
|
||
if payload.EffectiveRewardCoins > 0 {
|
||
state, err := s.repository.GetLuckyGiftDrawRewardState(ctx, event.AppCode, payload.DrawIDs)
|
||
if err != nil {
|
||
return false, false, receipt, s.markOutboxFailed(ctx, event, options, err, false)
|
||
}
|
||
alreadyGranted = state.AllGranted
|
||
if alreadyGranted {
|
||
walletTransactionID = state.WalletTransactionID
|
||
credited = true
|
||
} else {
|
||
var err error
|
||
receipt, err = s.creditLuckyGiftReward(ctx, event.AppCode, payload)
|
||
if err != nil {
|
||
return false, false, receipt, 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, receipt, err
|
||
}
|
||
return false, false, receipt, nil
|
||
}
|
||
if !alreadyGranted {
|
||
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil {
|
||
return credited, alreadyGranted, receipt, err
|
||
}
|
||
}
|
||
return credited, alreadyGranted, receipt, nil
|
||
}
|
||
|
||
type luckyGiftRewardReceipt struct {
|
||
WalletTransactionID string
|
||
CoinBalanceAfter int64
|
||
CoinFrozenAfter int64
|
||
BalanceVersion 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,
|
||
CountryID: cmd.CountryID,
|
||
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
|
||
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
|
||
}
|
||
// HTTP/gRPC 抽奖响应不再返回返奖后余额;成功落库后的余额变化通过钱包私有 IM 发送,Flutter 用 balance_after 覆盖本地余额。
|
||
if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, 3*time.Second); err != nil {
|
||
logx.Error(ctx, "lucky_gift_wallet_balance_notice_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,
|
||
CountryId: payload.CountryID,
|
||
})
|
||
if err != nil {
|
||
return luckyGiftRewardReceipt{}, err
|
||
}
|
||
return luckyGiftRewardReceipt{
|
||
WalletTransactionID: resp.GetTransactionId(),
|
||
CoinBalanceAfter: resp.GetBalance().GetAvailableAmount(),
|
||
CoinFrozenAfter: resp.GetBalance().GetFrozenAmount(),
|
||
BalanceVersion: resp.GetBalance().GetVersion(),
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) publishLuckyGiftWalletBalanceChanged(ctx context.Context, payload luckyGiftDrawnPayload, receipt luckyGiftRewardReceipt, timeout time.Duration) error {
|
||
if s.userPub == nil || payload.EffectiveRewardCoins <= 0 {
|
||
return nil
|
||
}
|
||
receipt.WalletTransactionID = strings.TrimSpace(receipt.WalletTransactionID)
|
||
if receipt.WalletTransactionID == "" {
|
||
return fmt.Errorf("lucky gift wallet transaction id is empty")
|
||
}
|
||
nowMs := s.now().UTC().UnixMilli()
|
||
commandID := "lucky_reward:" + payload.DrawID
|
||
noticePayload, err := json.Marshal(map[string]any{
|
||
"event_type": "WalletBalanceChanged",
|
||
"event_id": walletBalanceChangedEventID(receipt.WalletTransactionID, payload.UserID, "COIN"),
|
||
"app_code": payload.AppCode,
|
||
"transaction_id": receipt.WalletTransactionID,
|
||
"command_id": commandID,
|
||
"user_id": fmt.Sprintf("%d", payload.UserID),
|
||
"asset_type": "COIN",
|
||
"available_delta": payload.EffectiveRewardCoins,
|
||
"frozen_delta": 0,
|
||
"available_after": receipt.CoinBalanceAfter,
|
||
"frozen_after": receipt.CoinFrozenAfter,
|
||
"balance_version": receipt.BalanceVersion,
|
||
"created_at_ms": nowMs,
|
||
"source_created_at_ms": nowMs,
|
||
"metadata": map[string]any{
|
||
"transaction_id": receipt.WalletTransactionID,
|
||
"command_id": commandID,
|
||
"user_id": payload.UserID,
|
||
"draw_id": payload.DrawID,
|
||
"room_id": payload.RoomID,
|
||
"visible_region_id": payload.VisibleRegionID,
|
||
"region_id": payload.VisibleRegionID,
|
||
"country_id": payload.CountryID,
|
||
"gift_id": payload.GiftID,
|
||
"pool_id": payload.PoolID,
|
||
"amount": payload.EffectiveRewardCoins,
|
||
"reason": "lucky_gift_reward",
|
||
"created_at_ms": nowMs,
|
||
},
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
publishTimeout := timeout
|
||
if publishTimeout <= 0 {
|
||
publishTimeout = 5 * time.Second
|
||
}
|
||
publishCtx, cancel := context.WithTimeout(ctx, publishTimeout)
|
||
defer cancel()
|
||
return s.userPub.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
|
||
ToAccount: tencentim.FormatUserID(payload.UserID),
|
||
EventID: walletBalanceChangedEventID(receipt.WalletTransactionID, payload.UserID, "COIN"),
|
||
Desc: "WalletBalanceChanged",
|
||
Ext: "wallet_notice",
|
||
PayloadJSON: noticePayload,
|
||
})
|
||
}
|
||
|
||
func walletBalanceChangedEventID(transactionID string, userID int64, assetType string) string {
|
||
hashInput := fmt.Sprintf("%s|%s|%d|%s", strings.TrimSpace(transactionID), "WalletBalanceChanged", userID, strings.TrimSpace(assetType))
|
||
sum := sha256.Sum256([]byte(hashInput))
|
||
return "wev_" + hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDrawnPayload, options WorkerOptions) error {
|
||
if s.publisher == nil || payload.EffectiveRewardCoins <= 0 {
|
||
return nil
|
||
}
|
||
body, err := json.Marshal(luckyGiftRoomMessagePayload(payload))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 房间 IM 只是表现层事实同步,不参与钱包结算和 reward_status 收敛。
|
||
publishTimeout := options.PublishTimeout
|
||
if publishTimeout <= 0 {
|
||
publishTimeout = 5 * time.Second
|
||
}
|
||
publishCtx, cancel := context.WithTimeout(ctx, 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 s.broadcaster == nil || 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"`
|
||
SenderName string `json:"sender_name,omitempty"`
|
||
SenderAvatar string `json:"sender_avatar,omitempty"`
|
||
SenderDisplayUserID string `json:"sender_display_user_id,omitempty"`
|
||
VisibleRegionID int64 `json:"visible_region_id"`
|
||
CountryID int64 `json:"country_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 {
|
||
// 区域飘屏只承担大奖氛围,不再把 1x/2x/3x 的普通返奖广播到整区,避免高频低价值 IM 干扰用户。
|
||
// 跨房展示没有当前房间成员资料可兜底,缺 sender_name 时宁可跳过,也不能再让 Flutter 退回 `User {id}`。
|
||
return payload.VisibleRegionID > 0 && payload.EffectiveRewardCoins > 0 && payload.MultiplierPPM >= luckyGiftRegionBroadcastMinMultiplierPPM && strings.TrimSpace(payload.SenderName) != ""
|
||
}
|
||
|
||
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,
|
||
"sender_name": payload.SenderName,
|
||
"sender_avatar": payload.SenderAvatar,
|
||
"sender_display_user_id": payload.SenderDisplayUserID,
|
||
"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,
|
||
"sender_name": payload.SenderName,
|
||
"sender_avatar": payload.SenderAvatar,
|
||
"sender_display_user_id": payload.SenderDisplayUserID,
|
||
"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,
|
||
},
|
||
"sender": luckyGiftBroadcastProfilePayload(payload),
|
||
}
|
||
}
|
||
|
||
func (s *Service) enrichLuckyGiftSenderProfile(ctx context.Context, payload luckyGiftDrawnPayload) luckyGiftDrawnPayload {
|
||
if strings.TrimSpace(payload.SenderName) != "" && strings.TrimSpace(payload.SenderDisplayUserID) != "" {
|
||
return payload
|
||
}
|
||
if payload.SenderUserID <= 0 || s.senderProfileSource == nil {
|
||
return payload
|
||
}
|
||
profile, err := s.senderProfileSource.GetSenderProfile(ctx, payload.SenderUserID)
|
||
if err != nil {
|
||
// 用户资料只是 IM 展示快照;读取失败不能阻断已经落库的返奖和房间展示,后续仍可用 user_id 兜底排障。
|
||
logx.Error(ctx, "lucky_gift_sender_profile_resolve_failed", err,
|
||
slog.Int64("sender_user_id", payload.SenderUserID),
|
||
slog.String("draw_id", payload.DrawID),
|
||
)
|
||
return payload
|
||
}
|
||
if profile.UserID <= 0 {
|
||
profile.UserID = payload.SenderUserID
|
||
}
|
||
if strings.TrimSpace(payload.SenderName) == "" {
|
||
payload.SenderName = luckyGiftBroadcastDisplayName(profile)
|
||
}
|
||
if strings.TrimSpace(payload.SenderAvatar) == "" {
|
||
payload.SenderAvatar = strings.TrimSpace(profile.Avatar)
|
||
}
|
||
if strings.TrimSpace(payload.SenderDisplayUserID) == "" {
|
||
payload.SenderDisplayUserID = strings.TrimSpace(profile.Account)
|
||
}
|
||
return payload
|
||
}
|
||
|
||
func luckyGiftBroadcastDisplayName(profile broadcastservice.SenderProfile) string {
|
||
// Flutter 优先读 sender_name;这里按昵称、账号兜底,避免区域飘屏退化成 `User {id}`。
|
||
for _, value := range []string{profile.Nickname, profile.Account} {
|
||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||
return trimmed
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func luckyGiftBroadcastProfilePayload(payload luckyGiftDrawnPayload) map[string]any {
|
||
// 嵌套 sender 对象兼容 Flutter 现有解析,也让后续客户端不用再从扁平字段反推展示资料。
|
||
return map[string]any{
|
||
"user_id": payload.SenderUserID,
|
||
"display_user_id": payload.SenderDisplayUserID,
|
||
"nickname": payload.SenderName,
|
||
"avatar": payload.SenderAvatar,
|
||
}
|
||
}
|
||
|
||
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")
|
||
}
|