feat: simplify lucky gift settlement outbox

This commit is contained in:
zhx 2026-06-02 23:45:46 +08:00
parent 10a3df3a5a
commit 858b0993bc
13 changed files with 903 additions and 451 deletions

View File

@ -21,10 +21,12 @@ lucky_gift_worker:
enabled: true
worker_poll_interval: "1s"
worker_batch_size: 100
worker_concurrency: 4
worker_concurrency: 8
worker_lock_ttl: "30s"
worker_max_retry: 8
publish_timeout: "5s"
stats_refresh_interval: "10m"
stats_batch_size: 5000
tencent_im:
# Docker 本地联调全局/区域播报群;必须与 gateway/room-service 使用同一组腾讯 IM 应用配置。
enabled: true

View File

@ -19,12 +19,14 @@ red_packet_broadcast_worker:
enabled: true
lucky_gift_worker:
enabled: true
worker_poll_interval: "60s"
worker_batch_size: 10
worker_concurrency: 4
worker_poll_interval: "1s"
worker_batch_size: 100
worker_concurrency: 8
worker_lock_ttl: "30s"
worker_max_retry: 8
publish_timeout: "5s"
stats_refresh_interval: "10m"
stats_batch_size: 5000
tencent_im:
# 腾讯云 IM 应用配置;必须和 gateway 的 UserSig、room-service 房间群配置属于同一个 SDKAppID。
enabled: true

View File

@ -21,10 +21,12 @@ lucky_gift_worker:
enabled: true
worker_poll_interval: "1s"
worker_batch_size: 100
worker_concurrency: 4
worker_concurrency: 8
worker_lock_ttl: "30s"
worker_max_retry: 8
publish_timeout: "5s"
stats_refresh_interval: "10m"
stats_batch_size: 5000
tencent_im:
# activity-service 只负责全局/区域播报群;必须与 gateway/room-service 使用同一组腾讯 IM 应用配置。
enabled: true

View File

@ -256,7 +256,10 @@ CREATE TABLE IF NOT EXISTS lucky_draw_records (
KEY idx_lucky_draw_user (app_code, user_id, created_at_ms),
KEY idx_lucky_draw_gift (app_code, gift_id, created_at_ms),
KEY idx_lucky_draw_room (app_code, room_id, created_at_ms),
KEY idx_lucky_draw_status (app_code, reward_status, updated_at_ms)
KEY idx_lucky_draw_status (app_code, reward_status, updated_at_ms),
KEY idx_lucky_draw_created (app_code, created_at_ms, draw_id),
KEY idx_lucky_draw_pool_status_summary (app_code, pool_id, reward_status, created_at_ms, draw_id),
KEY idx_lucky_draw_gift_status_summary (app_code, pool_id, gift_id, reward_status, created_at_ms, draw_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物抽奖事实';
CREATE TABLE IF NOT EXISTS lucky_draw_pool_stats (
@ -297,6 +300,16 @@ CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_rooms (
PRIMARY KEY (app_code, pool_id, gift_id, room_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物汇总房间去重';
CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_cursors (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
cursor_name VARCHAR(64) NOT NULL COMMENT '游标名称',
last_draw_created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最后已聚合 draw 创建时间',
last_draw_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '同一毫秒内最后已聚合 draw ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, cursor_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物后台统计快照游标';
CREATE TABLE IF NOT EXISTS im_broadcast_outbox (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
event_id VARCHAR(128) NOT NULL COMMENT '事件幂等 ID',

View File

@ -586,5 +586,7 @@ func luckyGiftWorkerOptions(nodeID string, cfg config.LuckyGiftWorkerConfig) luc
LockTTL: cfg.WorkerLockTTL,
MaxRetry: cfg.WorkerMaxRetry,
PublishTimeout: cfg.PublishTimeout,
StatsInterval: cfg.StatsRefreshInterval,
StatsBatchSize: cfg.StatsBatchSize,
}
}

View File

@ -69,13 +69,15 @@ type RedPacketBroadcastWorkerConfig struct {
// LuckyGiftWorkerConfig 保存幸运礼物抽奖副作用补偿策略。
type LuckyGiftWorkerConfig struct {
Enabled bool `yaml:"enabled"`
WorkerPollInterval time.Duration `yaml:"worker_poll_interval"`
WorkerBatchSize int `yaml:"worker_batch_size"`
WorkerConcurrency int `yaml:"worker_concurrency"`
WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"`
WorkerMaxRetry int `yaml:"worker_max_retry"`
PublishTimeout time.Duration `yaml:"publish_timeout"`
Enabled bool `yaml:"enabled"`
WorkerPollInterval time.Duration `yaml:"worker_poll_interval"`
WorkerBatchSize int `yaml:"worker_batch_size"`
WorkerConcurrency int `yaml:"worker_concurrency"`
WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"`
WorkerMaxRetry int `yaml:"worker_max_retry"`
PublishTimeout time.Duration `yaml:"publish_timeout"`
StatsRefreshInterval time.Duration `yaml:"stats_refresh_interval"`
StatsBatchSize int `yaml:"stats_batch_size"`
}
// TencentIMConfig 描述 activity-service 调用腾讯云 IM REST API 的配置。
@ -168,13 +170,15 @@ func Default() Config {
Enabled: false,
},
LuckyGiftWorker: LuckyGiftWorkerConfig{
Enabled: false,
WorkerPollInterval: time.Second,
WorkerBatchSize: 100,
WorkerConcurrency: 4,
WorkerLockTTL: 30 * time.Second,
WorkerMaxRetry: 8,
PublishTimeout: 5 * time.Second,
Enabled: false,
WorkerPollInterval: time.Second,
WorkerBatchSize: 100,
WorkerConcurrency: 4,
WorkerLockTTL: 30 * time.Second,
WorkerMaxRetry: 8,
PublishTimeout: 5 * time.Second,
StatsRefreshInterval: 10 * time.Minute,
StatsBatchSize: 5000,
},
TencentIM: TencentIMConfig{
Enabled: false,

View File

@ -28,6 +28,9 @@ const (
BroadcastRoom = "room"
BroadcastRegion = "region"
BroadcastGlobal = "global"
EventTypeLuckyGiftDrawn = "LuckyGiftDrawn"
EventTypeLuckyGiftRewardSettlement = "LuckyGiftRewardSettlement"
)
// Tier 是单个体验池里的候选奖档;权重只塑造体验节奏,基础成本仍由 RTP 窗口兜住。

View File

@ -35,6 +35,7 @@ type Repository interface {
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
// 批量抽奖只产生一条聚合 outboxworker 处理后需要用 draw_ids 一次性收敛所有明细状态。
@ -152,17 +153,27 @@ type WorkerOptions struct {
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
@ -176,7 +187,7 @@ func (s *Service) ProcessPendingDrawOutbox(ctx context.Context, options WorkerOp
return 0, err
}
options = normalizeWorkerOptions(options)
// outbox 先抢占再处理,避免多实例同时给同一 draw 返奖或重复投递房间 IM
// outbox 只承载钱包返奖结算,避免多实例同时给同一 draw 返奖
events, err := s.repository.ClaimPendingLuckyGiftOutbox(ctx, options.WorkerID, s.now().UTC().UnixMilli(), options.LockTTL, options.BatchSize)
if err != nil {
return 0, err
@ -238,56 +249,55 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
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(eventCtx, event.AppCode, payload.DrawIDs)
state, err := s.repository.GetLuckyGiftDrawRewardState(ctx, event.AppCode, payload.DrawIDs)
if err != nil {
return s.markOutboxFailed(eventCtx, event, options, err, false)
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(eventCtx, event.AppCode, payload)
receipt, err := s.creditLuckyGiftReward(ctx, event.AppCode, payload)
if err != nil {
return s.markOutboxFailed(eventCtx, event, options, err, false)
return false, false, s.markOutboxFailed(ctx, event, options, err, false)
}
walletTransactionID = receipt.WalletTransactionID
credited = true
}
}
if credited && !alreadyGranted {
nowMS := s.now().UTC().UnixMilli()
if err := s.repository.MarkLuckyGiftDrawsGranted(eventCtx, event.AppCode, payload.DrawIDs, walletTransactionID, nowMS); err != nil {
return err
}
}
if s.publisher == nil {
// 已返奖但缺少房间发布依赖时不能把 draw 标记 failed否则会误导账务只让 outbox 保持可重试。
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift room publisher is not configured"), credited)
}
if shouldPublishLuckyGiftRegionBroadcast(payload) {
if s.broadcaster == nil {
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift region broadcaster is not configured"), credited)
}
// 区域播报先写 broadcast outbox不直接发 IM这里失败意味着大额表现未落地需要保留重试。
if err := s.publishLuckyGiftRegionBroadcast(eventCtx, payload); err != nil {
return s.markOutboxFailed(eventCtx, event, options, err, credited)
}
}
if err := s.publishLuckyGiftDrawn(eventCtx, payload, options); err != nil {
return s.markOutboxFailed(eventCtx, event, options, err, credited)
}
nowMS := s.now().UTC().UnixMilli()
if !credited {
if err := s.repository.MarkLuckyGiftDrawsGranted(eventCtx, event.AppCode, payload.DrawIDs, walletTransactionID, nowMS); err != nil {
return err
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 s.repository.MarkLuckyGiftOutboxDelivered(eventCtx, event, nowMS)
return credited, alreadyGranted, nil
}
type luckyGiftRewardReceipt struct {
@ -337,7 +347,7 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.
result.RewardStatus = domain.StatusGranted
result.WalletTransactionID = receipt.WalletTransactionID
result.CoinBalanceAfter = receipt.CoinBalanceAfter
if err := s.repository.MarkLuckyGiftDrawsGranted(ctx, payload.AppCode, payload.DrawIDs, receipt.WalletTransactionID, s.now().UTC().UnixMilli()); err != nil {
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),
@ -347,6 +357,25 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.
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")
@ -377,7 +406,7 @@ func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDr
if err != nil {
return err
}
// 房间 IM 只是表现层事实同步,不修改 Room Cell超时由 outbox 重试兜底
// 房间 IM 只是表现层事实同步,不参与钱包结算和 reward_status 收敛
publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout)
defer cancel()
return s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{
@ -411,12 +440,12 @@ func (s *Service) markOutboxFailed(ctx context.Context, event domain.DrawOutbox,
nextRetryCount := event.RetryCount + 1
if nextRetryCount >= options.MaxRetry {
if !alreadyCredited {
// 未返奖的 draw 可以整批标记 failed已返奖但 IM 失败时不能覆盖账务事实,只停 outbox 并告警
// 未返奖的 draw 可以整批标记 failed钱包已返奖时不能覆盖账务事实。
_ = s.repository.MarkLuckyGiftDrawsFailed(ctx, event.AppCode, drawIDsFromPayload(event.PayloadJSON), cause.Error(), nowMS)
}
return s.repository.MarkLuckyGiftOutboxFailed(ctx, event, nextRetryCount, cause.Error(), nowMS)
}
// 指数退避降低故障期间对钱包和 IM 的压力,同时保留 pending 状态给客户端展示“处理中”。
// 指数退避降低故障期间对钱包的压力,同时保留 pending 状态给客户端展示“处理中”。
nextRetryAtMS := nowMS + luckyGiftBackoff(nextRetryCount).Milliseconds()
return s.repository.MarkLuckyGiftOutboxRetryable(ctx, event, nextRetryCount, nextRetryAtMS, cause.Error(), nowMS)
}
@ -676,6 +705,15 @@ func normalizeWorkerOptions(options WorkerOptions) WorkerOptions {
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
}
@ -688,3 +726,14 @@ func luckyGiftBackoff(retryCount int) time.Duration {
}
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")
}

View File

@ -127,7 +127,7 @@ func TestDrawKeepsPendingWhenFastPathWalletFails(t *testing.T) {
}
}
func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomIM(t *testing.T) {
func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_test",
@ -172,18 +172,8 @@ func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomIM(t *testing.T) {
if wallet.last == nil || wallet.last.GetDrawId() != "lucky_draw_test" || wallet.last.GetTargetUserId() != 42 || wallet.last.GetAmount() != 300 {
t.Fatalf("wallet reward request mismatch: %+v", wallet.last)
}
if publisher.last.GroupID != "room-1" || publisher.last.Desc != "lucky_gift_drawn" || publisher.last.EventID != "lucky_gift_drawn:lucky_draw_test" {
t.Fatalf("room im message mismatch: %+v", publisher.last)
}
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_test" || broadcaster.last.BroadcastType != broadcastdomain.TypeLuckyGiftBigWin || broadcaster.last.RegionID != 210 {
t.Fatalf("region lucky broadcast mismatch: %+v", broadcaster.last)
}
var broadcastPayload map[string]any
if err := json.Unmarshal([]byte(broadcaster.last.PayloadJSON), &broadcastPayload); err != nil {
t.Fatalf("decode region broadcast payload failed: %v", err)
}
if broadcastPayload["event_type"] != "lucky_gift_drawn" || broadcastPayload["broadcast_type"] != broadcastdomain.TypeLuckyGiftBigWin || broadcastPayload["room_id"] != "room-1" {
t.Fatalf("region broadcast payload mismatch: %+v", broadcastPayload)
if publisher.last.EventID != "" || broadcaster.last.EventID != "" {
t.Fatalf("settlement worker must not publish presentation: publisher=%+v broadcaster=%+v", publisher.last, broadcaster.last)
}
if repository.grantedDrawID != "lucky_draw_test" || repository.deliveredOutboxID != "lucky_lucky_draw_test" {
t.Fatalf("repository settlement mismatch: granted=%s delivered=%s", repository.grantedDrawID, repository.deliveredOutboxID)
@ -240,6 +230,51 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
}
}
func TestProcessRewardSettlementOutboxDoesNotPublishPresentation(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_settle",
"app_code": "lalu",
"draw_id": "lucky_draw_settle",
"command_id": "cmd-gift-settle",
"pool_id": "super_lucky",
"user_id": 42,
"visible_region_id": 210,
"room_id": "room-1",
"gift_id": "rose",
"gift_count": 1,
"coin_spent": 100,
"effective_reward_coins": 300,
"multiplier_ppm": 3000000,
"created_at_ms": 1760000000000,
})
repository := &fakeLuckyGiftRepository{
outbox: []domain.DrawOutbox{{
AppCode: "lalu",
OutboxID: "lucky_reward_lucky_draw_settle",
EventType: domain.EventTypeLuckyGiftRewardSettlement,
PayloadJSON: string(payload),
}},
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
if err != nil {
t.Fatalf("ProcessPendingDrawOutbox failed: %v", err)
}
if processed != 1 || repository.deliveredOutboxID != "lucky_reward_lucky_draw_settle" {
t.Fatalf("settlement outbox should be delivered: processed=%d delivered=%s", processed, repository.deliveredOutboxID)
}
if wallet.last == nil || repository.grantedDrawID != "lucky_draw_settle" {
t.Fatalf("settlement should credit wallet and mark draw granted: wallet=%+v granted=%s", wallet.last, repository.grantedDrawID)
}
if publisher.last.EventID != "" {
t.Fatalf("settlement outbox must not publish presentation: %+v", publisher.last)
}
}
func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) {
drawIDs := make([]string, 0, 999)
for index := 0; index < 999; index++ {
@ -292,7 +327,7 @@ func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) {
}
}
func TestProcessPendingDrawOutboxGrantsRewardWhenRoomIMFails(t *testing.T) {
func TestProcessPendingDrawOutboxIgnoresRoomIMPublisher(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_im_fail",
@ -324,19 +359,16 @@ func TestProcessPendingDrawOutboxGrantsRewardWhenRoomIMFails(t *testing.T) {
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
if err != nil {
t.Fatalf("ProcessPendingDrawOutbox should schedule retry without failing batch: %v", err)
t.Fatalf("ProcessPendingDrawOutbox failed: %v", err)
}
if processed != 1 {
t.Fatalf("processed count mismatch: %d", processed)
}
if repository.grantedDrawID != "lucky_draw_im_fail" {
t.Fatalf("wallet-granted draw should be settled before IM retry, got %q", repository.grantedDrawID)
t.Fatalf("draw should be settled through wallet, got %q", repository.grantedDrawID)
}
if repository.deliveredOutboxID != "" {
t.Fatalf("outbox must not be delivered while IM failed")
}
if repository.retryableOutboxID != "lucky_lucky_draw_im_fail" {
t.Fatalf("IM failure should keep outbox retryable, got %q", repository.retryableOutboxID)
if repository.deliveredOutboxID != "lucky_lucky_draw_im_fail" || repository.retryableOutboxID != "" {
t.Fatalf("IM publisher must not affect settlement outbox: delivered=%s retryable=%s", repository.deliveredOutboxID, repository.retryableOutboxID)
}
}
@ -383,8 +415,8 @@ func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T)
if repository.grantedDrawID != "" {
t.Fatalf("already granted draw should not be marked again, got %q", repository.grantedDrawID)
}
if publisher.last.EventID != "lucky_gift_drawn:lucky_draw_granted" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" {
t.Fatalf("already granted draw should still publish IM and deliver outbox: publisher=%+v delivered=%s", publisher.last, repository.deliveredOutboxID)
if publisher.last.EventID != "" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" {
t.Fatalf("already granted draw should only deliver settlement outbox: publisher=%+v delivered=%s", publisher.last, repository.deliveredOutboxID)
}
}
@ -455,6 +487,10 @@ func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxFailed(context.Context, dom
return nil
}
func (r *fakeLuckyGiftRepository) RefreshLuckyGiftAdminStatsSnapshots(context.Context, int64, int) (int, error) {
return 0, nil
}
func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawGranted(_ context.Context, _ string, drawID string, _ string, _ int64) error {
r.grantedDrawID = drawID
return nil

View File

@ -134,11 +134,11 @@ type luckyBatchDrawState struct {
AtmosphereOut int64
ActivityReward int64
EffectiveReward int64
// NeedsOutbox 表示本批存在任意真实异步副作用:钱包加币、房间展示或阶段反馈
// 只要为 true本批所有子抽奖先统一 pendingworker 成功后一起 granted。
NeedsOutbox bool
StageFeedback bool
HighMultiplier bool
// 只有钱包结算会影响 reward_status=pendingIM/广播表现层不进入可靠 outbox
NeedsRewardSettlement bool
StageFeedback bool
Presentation bool
HighMultiplier bool
// Final* 是用户体验状态的最终值;批量抽奖不能每抽 UPDATE lucky_user_states。
FinalLossStreak int64
FinalCumulative int64
@ -370,13 +370,9 @@ func (r *Repository) executeOptimizedLuckyGiftDrawBatch(ctx context.Context, tx
state.AggregateSources = aggregate.BudgetSourcesJSON
state.AggregateStatus = aggregate.RewardStatus
state.AggregateCommandID = cmd.CommandID
if state.NeedsOutbox {
// 批量命令只需要一次钱包发放和一次房间表现;所有子抽奖共享这条 outbox 的最终收敛状态。
if state.NeedsRewardSettlement {
// 批量命令只需要一次钱包发放;所有子抽奖共享这条 settlement outbox 的最终收敛状态。
state.AggregateResult.RewardStatus = domain.StatusPending
for index := range state.Records {
state.Records[index].RewardStatus = domain.StatusPending
results[index].RewardStatus = domain.StatusPending
}
} else {
state.AggregateResult.RewardStatus = domain.StatusGranted
}
@ -435,9 +431,10 @@ func (r *Repository) stageLuckyBatchDraw(appCode string, baseConfig domain.Confi
state.FinalLossStreak++
}
state.StageFeedback = state.StageFeedback || stageFeedback
state.Presentation = state.Presentation || candidate.Presentation
state.HighMultiplier = state.HighMultiplier || candidate.HighMultiplier
if luckyDrawNeedsOutbox(candidate, stageFeedback) {
state.NeedsOutbox = true
if luckyDrawNeedsRewardSettlementOutbox(candidate) {
state.NeedsRewardSettlement = true
}
// 每个子抽必须有独立 draw_id保证审计、列表和重试读回都能定位到具体一次随机结果。
@ -576,12 +573,9 @@ func (r *Repository) persistLuckyBatchDraw(ctx context.Context, tx *sql.Tx, appC
if err := r.insertLuckyDrawRecords(ctx, tx, state.Records); err != nil {
return err
}
if err := r.incrementLuckyDrawPoolStats(ctx, tx, state.Records, nowMS); err != nil {
return err
}
if state.NeedsOutbox {
// 只有本批存在钱包/IM/反馈副作用时才产生 outbox纯 0 倍且无反馈的批次直接终态 granted。
return r.insertLuckyAggregateDrawOutbox(ctx, tx, appCode, cmd, state, nowMS)
if state.NeedsRewardSettlement {
// 主事务只写返奖结算 outbox后台统计由低频快照任务从 lucky_draw_records 增量刷新。
return r.insertLuckyAggregateRewardSettlementOutbox(ctx, tx, appCode, cmd, state, nowMS)
}
return nil
}
@ -669,69 +663,10 @@ type luckyDrawPoolStatDelta struct {
BaseRewardCoins int64
RoomAtmosphereRewardCoins int64
ActivitySubsidyCoins int64
PendingDraws int64
GrantedDraws int64
FailedDraws int64
UserIDs map[int64]struct{}
RoomIDs map[string]struct{}
}
func (r *Repository) incrementLuckyDrawPoolStats(ctx context.Context, tx *sql.Tx, records []luckyDrawRecordInput, nowMS int64) error {
deltas := map[luckyDrawPoolStatKey]*luckyDrawPoolStatDelta{}
for _, record := range records {
poolID := luckyPoolID(record.Config.GiftID)
giftID := strings.TrimSpace(record.Command.GiftID)
keys := []luckyDrawPoolStatKey{
{AppCode: record.AppCode, PoolID: poolID, GiftID: ""},
}
if giftID != "" {
keys = append(keys, luckyDrawPoolStatKey{AppCode: record.AppCode, PoolID: poolID, GiftID: giftID})
}
for _, key := range keys {
delta := deltas[key]
if delta == nil {
delta = &luckyDrawPoolStatDelta{UserIDs: map[int64]struct{}{}, RoomIDs: map[string]struct{}{}}
deltas[key] = delta
}
delta.TotalDraws++
delta.TotalSpentCoins += record.Command.CoinSpent
delta.TotalRewardCoins += record.Candidate.effectiveReward()
delta.BaseRewardCoins += record.Candidate.BaseReward
delta.RoomAtmosphereRewardCoins += record.Candidate.RoomReward
delta.ActivitySubsidyCoins += record.Candidate.ActivityReward
switch record.RewardStatus {
case domain.StatusPending:
delta.PendingDraws++
case domain.StatusGranted:
delta.GrantedDraws++
case domain.StatusFailed:
delta.FailedDraws++
}
if record.Command.UserID > 0 {
delta.UserIDs[record.Command.UserID] = struct{}{}
}
roomID := strings.TrimSpace(record.Command.RoomID)
if roomID != "" {
delta.RoomIDs[roomID] = struct{}{}
}
}
}
for key, delta := range deltas {
uniqueUsers, err := r.insertLuckyDrawStatUsers(ctx, tx, key, delta.UserIDs, nowMS)
if err != nil {
return err
}
uniqueRooms, err := r.insertLuckyDrawStatRooms(ctx, tx, key, delta.RoomIDs, nowMS)
if err != nil {
return err
}
if err := r.upsertLuckyDrawPoolStatDelta(ctx, tx, key, *delta, uniqueUsers, uniqueRooms, nowMS); err != nil {
return err
}
}
return nil
}
func (r *Repository) insertLuckyDrawStatUsers(ctx context.Context, tx *sql.Tx, key luckyDrawPoolStatKey, userIDs map[int64]struct{}, nowMS int64) (int64, error) {
var inserted int64
for userID := range userIDs {
@ -766,7 +701,7 @@ func (r *Repository) insertLuckyDrawStatRooms(ctx context.Context, tx *sql.Tx, k
return inserted, nil
}
func (r *Repository) upsertLuckyDrawPoolStatDelta(ctx context.Context, tx *sql.Tx, key luckyDrawPoolStatKey, delta luckyDrawPoolStatDelta, uniqueUsers int64, uniqueRooms int64, nowMS int64) error {
func (r *Repository) upsertLuckyDrawPoolStatsSnapshotDelta(ctx context.Context, tx *sql.Tx, key luckyDrawPoolStatKey, delta luckyDrawPoolStatDelta, uniqueUsers int64, uniqueRooms int64, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO lucky_draw_pool_stats (
app_code, pool_id, gift_id, total_draws, unique_users, unique_rooms, total_spent_coins,
@ -778,21 +713,186 @@ func (r *Repository) upsertLuckyDrawPoolStatDelta(ctx context.Context, tx *sql.T
unique_users = unique_users + VALUES(unique_users),
unique_rooms = unique_rooms + VALUES(unique_rooms),
total_spent_coins = total_spent_coins + VALUES(total_spent_coins),
total_reward_coins = total_reward_coins + VALUES(total_reward_coins),
base_reward_coins = base_reward_coins + VALUES(base_reward_coins),
room_atmosphere_reward_coins = room_atmosphere_reward_coins + VALUES(room_atmosphere_reward_coins),
activity_subsidy_coins = activity_subsidy_coins + VALUES(activity_subsidy_coins),
pending_draws = pending_draws + VALUES(pending_draws),
granted_draws = granted_draws + VALUES(granted_draws),
failed_draws = failed_draws + VALUES(failed_draws),
updated_at_ms = VALUES(updated_at_ms)`,
total_reward_coins = total_reward_coins + VALUES(total_reward_coins),
base_reward_coins = base_reward_coins + VALUES(base_reward_coins),
room_atmosphere_reward_coins = room_atmosphere_reward_coins + VALUES(room_atmosphere_reward_coins),
activity_subsidy_coins = activity_subsidy_coins + VALUES(activity_subsidy_coins),
updated_at_ms = VALUES(updated_at_ms)`,
key.AppCode, key.PoolID, key.GiftID, delta.TotalDraws, uniqueUsers, uniqueRooms, delta.TotalSpentCoins,
delta.TotalRewardCoins, delta.BaseRewardCoins, delta.RoomAtmosphereRewardCoins, delta.ActivitySubsidyCoins,
delta.PendingDraws, delta.GrantedDraws, delta.FailedDraws, nowMS, nowMS,
int64(0), int64(0), int64(0), nowMS, nowMS,
)
return err
}
type luckyDrawStatsCursor struct {
LastDrawCreatedAtMS int64
LastDrawID string
}
type luckyDrawStatsRecord struct {
AppCode string
DrawID string
PoolID string
GiftID string
UserID int64
RoomID string
TotalSpentCoins int64
TotalRewardCoins int64
BaseRewardCoins int64
RoomAtmosphereRewardCoins int64
ActivitySubsidyCoins int64
CreatedAtMS int64
}
func (r *Repository) RefreshLuckyGiftAdminStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error) {
if r == nil || r.db == nil {
return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if batchSize <= 0 {
batchSize = 5000
}
if batchSize > 50000 {
batchSize = 50000
}
appCode := appcode.FromContext(ctx)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
cursor, initialized, err := r.lockLuckyDrawStatsCursor(ctx, tx, appCode, nowMS)
if err != nil {
return 0, err
}
if initialized {
if err := tx.Commit(); err != nil {
return 0, err
}
return 0, nil
}
rows, err := tx.QueryContext(ctx, `
SELECT app_code, draw_id, pool_id, gift_id, user_id, room_id, coin_spent,
total_reward_coins, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
created_at_ms
FROM lucky_draw_records FORCE INDEX (idx_lucky_draw_created)
WHERE app_code = ?
AND (created_at_ms > ? OR (created_at_ms = ? AND draw_id > ?))
ORDER BY created_at_ms ASC, draw_id ASC
LIMIT ?`, appCode, cursor.LastDrawCreatedAtMS, cursor.LastDrawCreatedAtMS, cursor.LastDrawID, batchSize)
if err != nil {
return 0, err
}
defer rows.Close()
records := make([]luckyDrawStatsRecord, 0, batchSize)
for rows.Next() {
var record luckyDrawStatsRecord
if err := rows.Scan(&record.AppCode, &record.DrawID, &record.PoolID, &record.GiftID, &record.UserID, &record.RoomID,
&record.TotalSpentCoins, &record.TotalRewardCoins, &record.BaseRewardCoins,
&record.RoomAtmosphereRewardCoins, &record.ActivitySubsidyCoins, &record.CreatedAtMS); err != nil {
return 0, err
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return 0, err
}
if len(records) == 0 {
if err := tx.Commit(); err != nil {
return 0, err
}
return 0, nil
}
grouped := map[luckyDrawPoolStatKey]*luckyDrawPoolStatDelta{}
last := records[len(records)-1]
for _, record := range records {
keys := []luckyDrawPoolStatKey{{AppCode: record.AppCode, PoolID: record.PoolID, GiftID: ""}}
if giftID := strings.TrimSpace(record.GiftID); giftID != "" {
keys = append(keys, luckyDrawPoolStatKey{AppCode: record.AppCode, PoolID: record.PoolID, GiftID: giftID})
}
for _, key := range keys {
delta := grouped[key]
if delta == nil {
delta = &luckyDrawPoolStatDelta{UserIDs: map[int64]struct{}{}, RoomIDs: map[string]struct{}{}}
grouped[key] = delta
}
delta.TotalDraws++
delta.TotalSpentCoins += record.TotalSpentCoins
delta.TotalRewardCoins += record.TotalRewardCoins
delta.BaseRewardCoins += record.BaseRewardCoins
delta.RoomAtmosphereRewardCoins += record.RoomAtmosphereRewardCoins
delta.ActivitySubsidyCoins += record.ActivitySubsidyCoins
if record.UserID > 0 {
delta.UserIDs[record.UserID] = struct{}{}
}
if roomID := strings.TrimSpace(record.RoomID); roomID != "" {
delta.RoomIDs[roomID] = struct{}{}
}
}
}
for key, delta := range grouped {
uniqueUsers, err := r.insertLuckyDrawStatUsers(ctx, tx, key, delta.UserIDs, nowMS)
if err != nil {
return 0, err
}
uniqueRooms, err := r.insertLuckyDrawStatRooms(ctx, tx, key, delta.RoomIDs, nowMS)
if err != nil {
return 0, err
}
if err := r.upsertLuckyDrawPoolStatsSnapshotDelta(ctx, tx, key, *delta, uniqueUsers, uniqueRooms, nowMS); err != nil {
return 0, err
}
}
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_draw_pool_stat_cursors
SET last_draw_created_at_ms = ?, last_draw_id = ?, updated_at_ms = ?
WHERE app_code = ? AND cursor_name = 'draw_records'`,
last.CreatedAtMS, last.DrawID, nowMS, appCode,
); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return len(records), nil
}
func (r *Repository) lockLuckyDrawStatsCursor(ctx context.Context, tx *sql.Tx, appCode string, nowMS int64) (luckyDrawStatsCursor, bool, error) {
var cursor luckyDrawStatsCursor
err := tx.QueryRowContext(ctx, `
SELECT last_draw_created_at_ms, last_draw_id
FROM lucky_draw_pool_stat_cursors
WHERE app_code = ? AND cursor_name = 'draw_records'
FOR UPDATE`, appCode,
).Scan(&cursor.LastDrawCreatedAtMS, &cursor.LastDrawID)
if err == nil {
return cursor, false, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return luckyDrawStatsCursor{}, false, err
}
if err := tx.QueryRowContext(ctx, `
SELECT COALESCE(created_at_ms, 0), COALESCE(draw_id, '')
FROM lucky_draw_records FORCE INDEX (idx_lucky_draw_created)
WHERE app_code = ?
ORDER BY created_at_ms DESC, draw_id DESC
LIMIT 1`, appCode,
).Scan(&cursor.LastDrawCreatedAtMS, &cursor.LastDrawID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return luckyDrawStatsCursor{}, false, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_draw_pool_stat_cursors (
app_code, cursor_name, last_draw_created_at_ms, last_draw_id, created_at_ms, updated_at_ms
) VALUES (?, 'draw_records', ?, ?, ?, ?)`,
appCode, cursor.LastDrawCreatedAtMS, cursor.LastDrawID, nowMS, nowMS,
); err != nil {
return luckyDrawStatsCursor{}, false, err
}
return cursor, true, nil
}
// luckyDrawRecordArgs 固定 lucky_draw_records 的列顺序;集中维护可以避免批量 INSERT 和单条 INSERT 语义漂移。
func luckyDrawRecordArgs(input luckyDrawRecordInput) []any {
candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(input)
@ -830,9 +930,9 @@ func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, strin
return string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot)
}
// insertLuckyAggregateDrawOutbox 为整批抽奖只写一条副作用事件。
// draw_id 使用聚合响应的首个 drawdraw_ids 保存全部子抽;worker 用总 effective_reward_coins 发一次奖,再批量 granted。
func (r *Repository) insertLuckyAggregateDrawOutbox(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, state luckyBatchDrawState, nowMS int64) error {
// insertLuckyAggregateRewardSettlementOutbox 为整批抽奖写聚合返奖结算事件。
// draw_id 使用聚合响应的首个 drawdraw_ids 保存全部子抽;settlement 用总 effective_reward_coins 发一次奖,再批量 granted。
func (r *Repository) insertLuckyAggregateRewardSettlementOutbox(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, state luckyBatchDrawState, nowMS int64) error {
aggregate := state.AggregateResult
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
@ -862,14 +962,7 @@ func (r *Repository) insertLuckyAggregateDrawOutbox(ctx context.Context, tx *sql
"high_multiplier": aggregate.HighMultiplier,
"created_at_ms": nowMS,
})
_, err := tx.ExecContext(ctx, `
INSERT INTO activity_outbox (
app_code, outbox_id, event_type, payload, status, retry_count, next_retry_at_ms,
locked_by, lock_until_ms, last_error, created_at_ms, updated_at_ms
) VALUES (?, ?, 'LuckyGiftDrawn', ?, 'pending', 0, ?, '', 0, '', ?, ?)`,
appCode, "lucky_"+aggregate.DrawID, payload, nowMS, nowMS, nowMS,
)
return err
return r.insertLuckyRewardSettlementOutbox(ctx, tx, appCode, aggregate.DrawID, payload, nowMS)
}
func (r *Repository) executeSingleLuckyGiftDraw(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error) {
@ -1278,29 +1371,8 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
); err != nil {
return err
}
if err := r.incrementLuckyDrawPoolStats(ctx, tx, []luckyDrawRecordInput{{
AppCode: input.AppCode,
DrawID: input.DrawID,
Command: input.Command,
Config: input.Config,
ExperiencePool: input.ExperiencePool,
Candidate: input.Candidate,
Limited: input.Limited,
StageFeedback: input.StageFeedback,
GlobalWindow: input.GlobalWindow,
GiftWindow: input.GiftWindow,
PlatformPool: input.PlatformPool,
RoomPool: input.RoomPool,
GiftPool: input.GiftPool,
Atmosphere: input.Atmosphere,
BudgetSourcesJSON: input.BudgetSourcesJSON,
RewardStatus: input.RewardStatus,
NowMS: input.NowMS,
}}, input.NowMS); err != nil {
return err
}
if luckyDrawNeedsOutbox(input.Candidate, input.StageFeedback) {
// outbox 是钱包入账和房间 IM 的唯一异步出口;事务内只写事实,不调用外部 RPC。
if luckyDrawNeedsRewardSettlementOutbox(input.Candidate) {
// outbox 只记录钱包返奖结算;表现层 IM/广播不参与可靠补偿链路。
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:" + input.DrawID,
@ -1328,20 +1400,29 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
"high_multiplier": input.Candidate.HighMultiplier,
"created_at_ms": input.NowMS,
})
if _, err := tx.ExecContext(ctx, `
INSERT INTO activity_outbox (
app_code, outbox_id, event_type, payload, status, retry_count, next_retry_at_ms,
locked_by, lock_until_ms, last_error, created_at_ms, updated_at_ms
) VALUES (?, ?, 'LuckyGiftDrawn', ?, 'pending', 0, ?, '', 0, '', ?, ?)`,
input.AppCode, "lucky_"+input.DrawID, payload, input.NowMS, input.NowMS, input.NowMS,
); err != nil {
if err := r.insertLuckyRewardSettlementOutbox(ctx, tx, input.AppCode, input.DrawID, payload, input.NowMS); err != nil {
return err
}
}
return nil
}
// ClaimPendingLuckyGiftOutbox 抢占幸运礼物抽奖副作用。抽奖事实已在同一事务落库worker 只补偿钱包和房间 IM。
func (r *Repository) insertLuckyRewardSettlementOutbox(ctx context.Context, tx *sql.Tx, appCode string, drawID string, payload []byte, nowMS int64) error {
return r.insertLuckyDrawOutbox(ctx, tx, appCode, "lucky_reward_"+drawID, domain.EventTypeLuckyGiftRewardSettlement, payload, nowMS)
}
func (r *Repository) insertLuckyDrawOutbox(ctx context.Context, tx *sql.Tx, appCode string, outboxID string, eventType string, payload []byte, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO activity_outbox (
app_code, outbox_id, event_type, payload, status, retry_count, next_retry_at_ms,
locked_by, lock_until_ms, last_error, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 'pending', 0, ?, '', 0, '', ?, ?)`,
appCode, outboxID, eventType, payload, nowMS, nowMS, nowMS,
)
return err
}
// ClaimPendingLuckyGiftOutbox 抢占幸运礼物抽奖副作用。抽奖事实已在同一事务落库worker 优先补偿影响 reward_status 的结算事件。
func (r *Repository) ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.DrawOutbox, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -1366,34 +1447,50 @@ func (r *Repository) ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID s
defer func() { _ = tx.Rollback() }()
appCode := appcode.FromContext(ctx)
events, err := r.queryLuckyGiftOutboxCandidates(ctx, tx, `
SELECT app_code, outbox_id, event_type, COALESCE(CAST(payload AS CHAR), '{}'), retry_count, created_at_ms
FROM activity_outbox FORCE INDEX (idx_activity_outbox_lucky_retry)
WHERE app_code = ?
AND event_type = 'LuckyGiftDrawn'
AND status IN ('pending', 'retryable')
AND next_retry_at_ms <= ?
ORDER BY created_at_ms ASC, outbox_id ASC
LIMIT ?
FOR UPDATE`, batchSize, appCode, nowMS, batchSize)
if err != nil {
return nil, err
events := make([]domain.DrawOutbox, 0, batchSize)
priorityTypes := []string{
domain.EventTypeLuckyGiftRewardSettlement,
domain.EventTypeLuckyGiftDrawn,
}
if remaining := batchSize - len(events); remaining > 0 {
retryEvents, err := r.queryLuckyGiftOutboxCandidates(ctx, tx, `
for _, eventType := range priorityTypes {
candidates, err := r.queryLuckyGiftOutboxCandidates(ctx, tx, `
SELECT app_code, outbox_id, event_type, COALESCE(CAST(payload AS CHAR), '{}'), retry_count, created_at_ms
FROM activity_outbox FORCE INDEX (idx_activity_outbox_lucky_lock)
FROM activity_outbox FORCE INDEX (idx_activity_outbox_lucky_retry)
WHERE app_code = ?
AND event_type = 'LuckyGiftDrawn'
AND status = 'delivering'
AND lock_until_ms <= ?
AND event_type = ?
AND status IN ('pending', 'retryable')
AND next_retry_at_ms <= ?
ORDER BY created_at_ms ASC, outbox_id ASC
LIMIT ?
FOR UPDATE`, remaining, appCode, nowMS, remaining)
FOR UPDATE`, batchSize, appCode, eventType, nowMS, batchSize)
if err != nil {
return nil, err
}
events = append(events, retryEvents...)
if len(candidates) > 0 {
events = append(events, candidates...)
break
}
}
if len(events) == 0 {
for _, eventType := range priorityTypes {
retryEvents, err := r.queryLuckyGiftOutboxCandidates(ctx, tx, `
SELECT app_code, outbox_id, event_type, COALESCE(CAST(payload AS CHAR), '{}'), retry_count, created_at_ms
FROM activity_outbox FORCE INDEX (idx_activity_outbox_lucky_lock)
WHERE app_code = ?
AND event_type = ?
AND status = 'delivering'
AND lock_until_ms <= ?
ORDER BY created_at_ms ASC, outbox_id ASC
LIMIT ?
FOR UPDATE`, batchSize, appCode, eventType, nowMS, batchSize)
if err != nil {
return nil, err
}
if len(retryEvents) > 0 {
events = append(events, retryEvents...)
break
}
}
}
lockUntilMS := nowMS + lockTTL.Milliseconds()
for _, event := range events {
@ -1566,11 +1663,6 @@ func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, dra
if err != nil {
return err
}
deltas, err := r.selectLuckyDrawStatusDeltas(ctx, tx, appCode, chunk, status, onlyPending)
if err != nil {
_ = tx.Rollback()
return err
}
args := []any{status, walletTransactionID, failureReason, nowMS, appCode}
for _, drawID := range chunk {
args = append(args, drawID)
@ -1588,10 +1680,6 @@ func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, dra
_ = tx.Rollback()
return err
}
if err := r.applyLuckyDrawStatusDeltas(ctx, tx, appCode, deltas, status, nowMS); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
@ -1599,96 +1687,6 @@ func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, dra
return nil
}
type luckyDrawStatusDelta struct {
PoolID string
GiftID string
OldStatus string
Count int64
}
func (r *Repository) selectLuckyDrawStatusDeltas(ctx context.Context, tx *sql.Tx, appCode string, drawIDs []string, targetStatus string, onlyPending bool) ([]luckyDrawStatusDelta, error) {
args := []any{appCode}
for _, drawID := range drawIDs {
args = append(args, drawID)
}
args = append(args, targetStatus)
query := `
SELECT pool_id, gift_id, reward_status, COUNT(*)
FROM lucky_draw_records
WHERE app_code = ? AND draw_id IN (` + luckySQLPlaceholders(len(drawIDs)) + `) AND reward_status <> ?`
if onlyPending {
query += ` AND reward_status = ?`
args = append(args, domain.StatusPending)
}
query += ` GROUP BY pool_id, gift_id, reward_status`
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
deltas := []luckyDrawStatusDelta{}
for rows.Next() {
var delta luckyDrawStatusDelta
if err := rows.Scan(&delta.PoolID, &delta.GiftID, &delta.OldStatus, &delta.Count); err != nil {
return nil, err
}
deltas = append(deltas, delta)
}
return deltas, rows.Err()
}
func (r *Repository) applyLuckyDrawStatusDeltas(ctx context.Context, tx *sql.Tx, appCode string, deltas []luckyDrawStatusDelta, targetStatus string, nowMS int64) error {
for _, delta := range deltas {
keys := []luckyDrawPoolStatKey{{AppCode: appCode, PoolID: delta.PoolID, GiftID: ""}}
if strings.TrimSpace(delta.GiftID) != "" {
keys = append(keys, luckyDrawPoolStatKey{AppCode: appCode, PoolID: delta.PoolID, GiftID: delta.GiftID})
}
for _, key := range keys {
if err := r.applyLuckyDrawStatusDelta(ctx, tx, key, delta.OldStatus, targetStatus, delta.Count, nowMS); err != nil {
return err
}
}
}
return nil
}
func (r *Repository) applyLuckyDrawStatusDelta(ctx context.Context, tx *sql.Tx, key luckyDrawPoolStatKey, oldStatus string, targetStatus string, count int64, nowMS int64) error {
pendingDelta, grantedDelta, failedDelta := luckyStatusCounterDelta(oldStatus, targetStatus, count)
if pendingDelta == 0 && grantedDelta == 0 && failedDelta == 0 {
return nil
}
_, err := tx.ExecContext(ctx, `
UPDATE lucky_draw_pool_stats
SET pending_draws = pending_draws + ?,
granted_draws = granted_draws + ?,
failed_draws = failed_draws + ?,
updated_at_ms = ?
WHERE app_code = ? AND pool_id = ? AND gift_id = ?`,
pendingDelta, grantedDelta, failedDelta, nowMS, key.AppCode, key.PoolID, key.GiftID,
)
return err
}
func luckyStatusCounterDelta(oldStatus string, targetStatus string, count int64) (pending int64, granted int64, failed int64) {
switch oldStatus {
case domain.StatusPending:
pending -= count
case domain.StatusGranted:
granted -= count
case domain.StatusFailed:
failed -= count
}
switch targetStatus {
case domain.StatusPending:
pending += count
case domain.StatusGranted:
granted += count
case domain.StatusFailed:
failed += count
}
return pending, granted, failed
}
// normalizeLuckyDrawIDs 去掉空值和重复值outbox payload 是补偿输入,必须先清洗再拼 SQL IN。
func normalizeLuckyDrawIDs(drawIDs []string) []string {
seen := map[string]bool{}
@ -1820,8 +1818,7 @@ func (r *Repository) getLuckyGiftDrawSummaryFromStats(ctx context.Context, query
summary.PoolID = query.PoolID
err := r.db.QueryRowContext(ctx, `
SELECT total_draws, unique_users, unique_rooms, total_spent_coins, total_reward_coins,
base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
pending_draws, granted_draws, failed_draws
base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins
FROM lucky_draw_pool_stats
WHERE app_code = ? AND pool_id = ? AND gift_id = ?`,
appcode.FromContext(ctx), query.PoolID, strings.TrimSpace(query.GiftID),
@ -1834,22 +1831,88 @@ func (r *Repository) getLuckyGiftDrawSummaryFromStats(ctx context.Context, query
&summary.BaseRewardCoins,
&summary.RoomAtmosphereRewardCoins,
&summary.ActivitySubsidyCoins,
&summary.PendingDraws,
&summary.GrantedDraws,
&summary.FailedDraws,
)
if errors.Is(err, sql.ErrNoRows) {
return r.getLuckyGiftDrawSummaryFromRecords(ctx, query)
return summary, nil
}
if err != nil {
return domain.DrawSummary{}, err
}
if err := r.loadLuckyGiftDrawStatusCounts(ctx, query, &summary); err != nil {
return domain.DrawSummary{}, err
}
if summary.TotalSpentCoins > 0 {
summary.ActualRTPPPM = summary.TotalRewardCoins * luckyPPMScale / summary.TotalSpentCoins
}
return summary, nil
}
func (r *Repository) loadLuckyGiftDrawStatusCounts(ctx context.Context, query domain.DrawQuery, summary *domain.DrawSummary) error {
if summary == nil {
return nil
}
appCode := appcode.FromContext(ctx)
where := []string{"app_code = ?"}
args := []any{appCode}
if strings.TrimSpace(query.PoolID) != "" {
where = append(where, "pool_id = ?")
args = append(args, query.PoolID)
}
indexName := "idx_lucky_draw_pool_status_summary"
if strings.TrimSpace(query.GiftID) != "" {
indexName = "idx_lucky_draw_gift_status_summary"
where = append(where, "gift_id = ?")
args = append(args, strings.TrimSpace(query.GiftID))
}
if cursor, exists, err := r.getLuckyDrawStatsCursor(ctx, appCode); err != nil {
return err
} else if exists && (cursor.LastDrawCreatedAtMS > 0 || cursor.LastDrawID != "") {
where = append(where, "(created_at_ms < ? OR (created_at_ms = ? AND draw_id <= ?))")
args = append(args, cursor.LastDrawCreatedAtMS, cursor.LastDrawCreatedAtMS, cursor.LastDrawID)
}
rows, err := r.db.QueryContext(ctx, `
SELECT reward_status, COUNT(*)
FROM lucky_draw_records FORCE INDEX (`+indexName+`)
WHERE `+strings.Join(where, " AND ")+`
GROUP BY reward_status`, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var status string
var count int64
if err := rows.Scan(&status, &count); err != nil {
return err
}
switch status {
case domain.StatusPending:
summary.PendingDraws = count
case domain.StatusGranted:
summary.GrantedDraws = count
case domain.StatusFailed:
summary.FailedDraws = count
}
}
return rows.Err()
}
func (r *Repository) getLuckyDrawStatsCursor(ctx context.Context, appCode string) (luckyDrawStatsCursor, bool, error) {
var cursor luckyDrawStatsCursor
err := r.db.QueryRowContext(ctx, `
SELECT last_draw_created_at_ms, last_draw_id
FROM lucky_draw_pool_stat_cursors
WHERE app_code = ? AND cursor_name = 'draw_records'`, appCode,
).Scan(&cursor.LastDrawCreatedAtMS, &cursor.LastDrawID)
if errors.Is(err, sql.ErrNoRows) {
return luckyDrawStatsCursor{}, false, nil
}
if err != nil {
return luckyDrawStatsCursor{}, false, err
}
return cursor, true, nil
}
func luckyDrawWhereClause(appCode string, query domain.DrawQuery) (string, []any) {
where := []string{"app_code = ?"}
args := []any{appCode}
@ -2747,17 +2810,15 @@ func luckyBudgetSourcesJSON(candidate luckyCandidate) string {
return string(raw)
}
func luckyInitialRewardStatus(candidate luckyCandidate, stageFeedback bool) string {
// 没有金币返还、也没有房间展示副作用时,抽奖事实已经终态化;不能留给不存在的 outbox 消费。
if !luckyDrawNeedsOutbox(candidate, stageFeedback) {
return domain.StatusGranted
func luckyInitialRewardStatus(candidate luckyCandidate, _ bool) string {
if luckyDrawNeedsRewardSettlementOutbox(candidate) {
return domain.StatusPending
}
return domain.StatusPending
return domain.StatusGranted
}
func luckyDrawNeedsOutbox(candidate luckyCandidate, stageFeedback bool) bool {
// outbox 只承载真实异步副作用:钱包入账、房间 IM 展示或阶段反馈展示。
return candidate.effectiveReward() > 0 || stageFeedback || candidate.Presentation
func luckyDrawNeedsRewardSettlementOutbox(candidate luckyCandidate) bool {
return candidate.effectiveReward() > 0
}
func luckyEnabledReason(enabled bool) string {

View File

@ -1,9 +1,13 @@
package mysql
import (
"context"
"strings"
"testing"
"time"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
domain "hyapp/services/activity-service/internal/domain/luckygift"
)
@ -36,6 +40,148 @@ func TestLuckyRuntimeConfigScalesTierRewardsByActualSpendButKeepsRiskCapsAbsolut
}
}
func TestClaimPendingLuckyGiftOutboxPrioritizesRewardSettlement(t *testing.T) {
schema := mysqlschema.New(t, mysqlschema.Config{
EnvVar: "ACTIVITY_SERVICE_MYSQL_TEST_DSN",
InitDBPath: mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1), "..", "..", "..", "deploy", "mysql", "initdb", "001_activity_service.sql"),
DatabasePrefix: "hyapp_activity_lucky_outbox_test",
})
repository, err := Open(context.Background(), schema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
ctx := appcode.WithContext(context.Background(), "lalu")
nowMS := int64(1_700_000_000_000)
_, err = repository.db.ExecContext(ctx, `
INSERT INTO activity_outbox (
app_code, outbox_id, event_type, payload, status, retry_count, next_retry_at_ms,
locked_by, lock_until_ms, last_error, created_at_ms, updated_at_ms
) VALUES
('lalu', 'lucky_legacy_draw_1', ?, '{}', 'pending', 0, ?, '', 0, '', ?, ?),
('lalu', 'lucky_reward_draw_1', ?, '{}', 'pending', 0, ?, '', 0, '', ?, ?)`,
domain.EventTypeLuckyGiftDrawn, nowMS-100, nowMS-200, nowMS-200,
domain.EventTypeLuckyGiftRewardSettlement, nowMS-100, nowMS-100, nowMS-100,
)
if err != nil {
t.Fatalf("seed activity_outbox: %v", err)
}
events, err := repository.ClaimPendingLuckyGiftOutbox(ctx, "worker-priority", nowMS, 30*time.Second, 10)
if err != nil {
t.Fatalf("ClaimPendingLuckyGiftOutbox failed: %v", err)
}
if len(events) != 1 || events[0].EventType != domain.EventTypeLuckyGiftRewardSettlement || events[0].OutboxID != "lucky_reward_draw_1" {
t.Fatalf("claim should only return reward settlement first, got %+v", events)
}
var legacyStatus string
if err := repository.db.QueryRowContext(ctx, `
SELECT status FROM activity_outbox WHERE app_code = 'lalu' AND outbox_id = 'lucky_legacy_draw_1'`).Scan(&legacyStatus); err != nil {
t.Fatalf("read legacy outbox status: %v", err)
}
if legacyStatus != "pending" {
t.Fatalf("legacy outbox should remain pending while reward settlement exists, got %s", legacyStatus)
}
}
func TestRefreshLuckyGiftAdminStatsSnapshotsAdvancesCursorByBatch(t *testing.T) {
schema := mysqlschema.New(t, mysqlschema.Config{
EnvVar: "ACTIVITY_SERVICE_MYSQL_TEST_DSN",
InitDBPath: mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1), "..", "..", "..", "deploy", "mysql", "initdb", "001_activity_service.sql"),
DatabasePrefix: "hyapp_activity_lucky_stats_test",
})
repository, err := Open(context.Background(), schema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
ctx := appcode.WithContext(context.Background(), "lalu")
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO lucky_draw_pool_stat_cursors (
app_code, cursor_name, last_draw_created_at_ms, last_draw_id, created_at_ms, updated_at_ms
) VALUES ('lalu', 'draw_records', 0, '', 1, 1)`); err != nil {
t.Fatalf("seed stats cursor: %v", err)
}
seedLuckyDrawRecordForStats(t, repository, "draw_001", "cmd_001", domain.StatusGranted, 100, 100, 300)
seedLuckyDrawRecordForStats(t, repository, "draw_002", "cmd_002", domain.StatusPending, 200, 150, 0)
processed, err := repository.RefreshLuckyGiftAdminStatsSnapshots(ctx, 300, 1)
if err != nil {
t.Fatalf("first refresh failed: %v", err)
}
if processed != 1 {
t.Fatalf("first refresh should process one row, got %d", processed)
}
assertLuckyStatsTotal(t, repository, "", 1, 100, 300)
assertLuckyStatsCursor(t, repository, 100, "draw_001")
processed, err = repository.RefreshLuckyGiftAdminStatsSnapshots(ctx, 400, 10)
if err != nil {
t.Fatalf("second refresh failed: %v", err)
}
if processed != 1 {
t.Fatalf("second refresh should process remaining row, got %d", processed)
}
assertLuckyStatsTotal(t, repository, "", 2, 250, 300)
assertLuckyStatsCursor(t, repository, 200, "draw_002")
}
func seedLuckyDrawRecordForStats(t *testing.T, repository *Repository, drawID string, commandID string, status string, createdAtMS int64, spent int64, reward int64) {
t.Helper()
_, err := repository.db.ExecContext(context.Background(), `
INSERT INTO lucky_draw_records (
app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, pool_id, gift_id,
coin_spent, rule_version, experience_pool, rtp_window_index, gift_rtp_window_index,
selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
effective_reward_coins, budget_sources_json, stage_feedback, high_multiplier,
candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status,
reward_transaction_id, reward_failure_reason, paid_at_ms, created_at_ms, updated_at_ms
) VALUES (
'lalu', ?, ?, 42, 'device-1', 'room-1', 'anchor-1', 'lucky', 'rose',
?, 1, 'novice', 1, 1,
'tier', ?, 0, 0,
?, '{}', 0, 0,
'{}', '{}', '{}', ?,
'', '', ?, ?, ?
)`, drawID, commandID, spent, reward, reward, status, createdAtMS, createdAtMS, createdAtMS)
if err != nil {
t.Fatalf("seed draw record %s: %v", drawID, err)
}
}
func assertLuckyStatsTotal(t *testing.T, repository *Repository, giftID string, wantDraws int64, wantSpent int64, wantReward int64) {
t.Helper()
var draws, spent, reward int64
if err := repository.db.QueryRowContext(context.Background(), `
SELECT total_draws, total_spent_coins, total_reward_coins
FROM lucky_draw_pool_stats
WHERE app_code = 'lalu' AND pool_id = 'lucky' AND gift_id = ?`, giftID,
).Scan(&draws, &spent, &reward); err != nil {
t.Fatalf("read stats total: %v", err)
}
if draws != wantDraws || spent != wantSpent || reward != wantReward {
t.Fatalf("stats mismatch: draws=%d spent=%d reward=%d want draws=%d spent=%d reward=%d", draws, spent, reward, wantDraws, wantSpent, wantReward)
}
}
func assertLuckyStatsCursor(t *testing.T, repository *Repository, wantCreatedAtMS int64, wantDrawID string) {
t.Helper()
var createdAtMS int64
var drawID string
if err := repository.db.QueryRowContext(context.Background(), `
SELECT last_draw_created_at_ms, last_draw_id
FROM lucky_draw_pool_stat_cursors
WHERE app_code = 'lalu' AND cursor_name = 'draw_records'`,
).Scan(&createdAtMS, &drawID); err != nil {
t.Fatalf("read stats cursor: %v", err)
}
if createdAtMS != wantCreatedAtMS || drawID != wantDrawID {
t.Fatalf("cursor mismatch: created_at=%d draw_id=%s want created_at=%d draw_id=%s", createdAtMS, drawID, wantCreatedAtMS, wantDrawID)
}
}
func TestLuckyRuntimeConfigFromRuleConfigUsesV2PoolAndStageTiers(t *testing.T) {
ruleConfig := domain.RuleConfig{
AppCode: "lalu",
@ -399,29 +545,31 @@ func TestLuckyInitialRewardStatusGrantsZeroRewardWithoutOutbox(t *testing.T) {
if status := luckyInitialRewardStatus(luckyCandidate{TierID: "none"}, false); status != domain.StatusGranted {
t.Fatalf("zero reward without presentation should be granted, got %s", status)
}
if luckyDrawNeedsOutbox(luckyCandidate{TierID: "none"}, false) {
if luckyDrawNeedsRewardSettlementOutbox(luckyCandidate{TierID: "none"}) {
t.Fatalf("zero reward without presentation must not emit outbox")
}
}
func TestLuckyInitialRewardStatusKeepsAsyncSideEffectsPending(t *testing.T) {
func TestLuckyInitialRewardStatusOnlyWaitsForWalletSettlement(t *testing.T) {
tests := []struct {
name string
candidate luckyCandidate
stageFeedback bool
wantStatus string
wantReward bool
}{
{name: "wallet credit", candidate: luckyCandidate{TierID: "hit", BaseReward: 10}},
{name: "presentation candidate", candidate: luckyCandidate{TierID: "stage", Presentation: true}},
{name: "stage feedback", candidate: luckyCandidate{TierID: "none"}, stageFeedback: true},
{name: "wallet credit", candidate: luckyCandidate{TierID: "hit", BaseReward: 10}, wantStatus: domain.StatusPending, wantReward: true},
{name: "presentation candidate", candidate: luckyCandidate{TierID: "stage", Presentation: true}, wantStatus: domain.StatusGranted},
{name: "stage feedback", candidate: luckyCandidate{TierID: "none"}, stageFeedback: true, wantStatus: domain.StatusGranted},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if status := luckyInitialRewardStatus(test.candidate, test.stageFeedback); status != domain.StatusPending {
t.Fatalf("async side effect should stay pending, got %s", status)
if status := luckyInitialRewardStatus(test.candidate, test.stageFeedback); status != test.wantStatus {
t.Fatalf("reward status mismatch: got %s want %s", status, test.wantStatus)
}
if !luckyDrawNeedsOutbox(test.candidate, test.stageFeedback) {
t.Fatalf("async side effect should emit outbox")
if got := luckyDrawNeedsRewardSettlementOutbox(test.candidate); got != test.wantReward {
t.Fatalf("reward settlement outbox mismatch: got %v want %v", got, test.wantReward)
}
})
}

View File

@ -0,0 +1,196 @@
package mysql
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
domain "hyapp/services/activity-service/internal/domain/luckygift"
luckygiftservice "hyapp/services/activity-service/internal/service/luckygift"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
func TestLuckyGiftRewardSettlementOutboxPressureLocal(t *testing.T) {
if os.Getenv("RUN_LUCKY_GIFT_SETTLEMENT_PRESSURE") != "1" {
t.Skip("set RUN_LUCKY_GIFT_SETTLEMENT_PRESSURE=1 to run local settlement outbox pressure test")
}
cases := []struct {
name string
total int
batchSize int
concurrency int
}{
{name: "1k_batch100_c4", total: 1000, batchSize: 100, concurrency: 4},
{name: "5k_batch100_c4", total: 5000, batchSize: 100, concurrency: 4},
{name: "10k_batch200_c8", total: 10000, batchSize: 200, concurrency: 8},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runLuckyGiftSettlementPressureCase(t, tc.total, tc.batchSize, tc.concurrency)
})
}
}
func runLuckyGiftSettlementPressureCase(t *testing.T, total int, batchSize int, concurrency int) {
t.Helper()
schema := mysqlschema.New(t, mysqlschema.Config{
EnvVar: "ACTIVITY_SERVICE_MYSQL_TEST_DSN",
InitDBPath: mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1), "..", "..", "..", "deploy", "mysql", "initdb", "001_activity_service.sql"),
DatabasePrefix: "hy_lucky_press",
})
repository, err := Open(context.Background(), schema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
seedStart := time.Now()
seedLuckyGiftSettlementPressureRows(t, repository, total)
seedElapsed := time.Since(seedStart)
wallet := &pressureLuckyGiftWallet{}
service := luckygiftservice.New(repository, luckygiftservice.WithWallet(wallet))
ctx := appcode.WithContext(context.Background(), "lalu")
options := luckygiftservice.WorkerOptions{
WorkerID: "pressure-worker",
BatchSize: batchSize,
Concurrency: concurrency,
LockTTL: time.Minute,
MaxRetry: 3,
}
start := time.Now()
processedTotal := 0
loops := 0
for processedTotal < total {
processed, err := service.ProcessPendingDrawOutbox(ctx, options)
if err != nil {
t.Fatalf("process settlement outbox: %v", err)
}
if processed == 0 {
break
}
processedTotal += processed
loops++
}
elapsed := time.Since(start)
granted := countLuckyGiftPressureRows(t, repository, "lucky_draw_records", "reward_status = 'granted'")
delivered := countLuckyGiftPressureRows(t, repository, "activity_outbox", "status = 'delivered'")
if processedTotal != total || granted != int64(total) || delivered != int64(total) || wallet.calls.Load() != int64(total) {
t.Fatalf("pressure result mismatch: processed=%d granted=%d delivered=%d wallet_calls=%d total=%d",
processedTotal, granted, delivered, wallet.calls.Load(), total)
}
t.Logf("settlement_pressure total=%d batch=%d concurrency=%d loops=%d seed_ms=%d process_ms=%d throughput=%.2f events/s avg_event_ms=%.4f",
total, batchSize, concurrency, loops, seedElapsed.Milliseconds(), elapsed.Milliseconds(),
float64(total)/elapsed.Seconds(), float64(elapsed.Microseconds())/1000.0/float64(total))
}
func seedLuckyGiftSettlementPressureRows(t *testing.T, repository *Repository, total int) {
t.Helper()
const chunkSize = 500
for start := 0; start < total; start += chunkSize {
end := start + chunkSize
if end > total {
end = total
}
if err := seedLuckyGiftSettlementPressureChunk(repository, start, end); err != nil {
t.Fatalf("seed pressure rows %d-%d: %v", start, end, err)
}
}
}
func seedLuckyGiftSettlementPressureChunk(repository *Repository, start int, end int) error {
nowMS := time.Now().UTC().UnixMilli()
drawPlaceholders := make([]string, 0, end-start)
drawArgs := make([]any, 0, (end-start)*31)
outboxPlaceholders := make([]string, 0, end-start)
outboxArgs := make([]any, 0, (end-start)*12)
for index := start; index < end; index++ {
drawID := fmt.Sprintf("pressure_draw_%08d", index)
commandID := fmt.Sprintf("pressure_cmd_%08d", index)
userID := int64(100000 + index%1000)
roomID := fmt.Sprintf("room_%03d", index%100)
createdAtMS := nowMS + int64(index)
drawPlaceholders = append(drawPlaceholders, "("+luckySQLPlaceholders(31)+")")
drawArgs = append(drawArgs,
"lalu", drawID, commandID, userID, "pressure-device", roomID, "pressure-anchor", "lucky", "rose",
int64(100), int64(1), domain.PoolNovice, int64(1), int64(1),
"hit", int64(10), int64(0), int64(0),
int64(10), "{}", false, false,
"{}", "{}", "{}", domain.StatusPending, "", "",
createdAtMS, createdAtMS, createdAtMS,
)
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:" + drawID,
"app_code": "lalu",
"draw_id": drawID,
"command_id": commandID,
"pool_id": "lucky",
"user_id": userID,
"room_id": roomID,
"gift_id": "rose",
"gift_count": 1,
"coin_spent": 100,
"effective_reward_coins": 10,
"created_at_ms": createdAtMS,
})
outboxPlaceholders = append(outboxPlaceholders, "("+luckySQLPlaceholders(12)+")")
outboxArgs = append(outboxArgs,
"lalu", "lucky_reward_"+drawID, domain.EventTypeLuckyGiftRewardSettlement, payload,
"pending", 0, createdAtMS, "", int64(0), "", createdAtMS, createdAtMS,
)
}
ctx := context.Background()
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO lucky_draw_records (
app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, pool_id, gift_id,
coin_spent, rule_version, experience_pool, rtp_window_index, gift_rtp_window_index,
selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
effective_reward_coins, budget_sources_json, stage_feedback, high_multiplier,
candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status,
reward_transaction_id, reward_failure_reason, paid_at_ms, created_at_ms, updated_at_ms
) VALUES `+strings.Join(drawPlaceholders, ","), drawArgs...); err != nil {
return err
}
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO activity_outbox (
app_code, outbox_id, event_type, payload, status, retry_count, next_retry_at_ms,
locked_by, lock_until_ms, last_error, created_at_ms, updated_at_ms
) VALUES `+strings.Join(outboxPlaceholders, ","), outboxArgs...); err != nil {
return err
}
return nil
}
func countLuckyGiftPressureRows(t *testing.T, repository *Repository, table string, where string) int64 {
t.Helper()
var count int64
if err := repository.db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table+" WHERE app_code = 'lalu' AND "+where).Scan(&count); err != nil {
t.Fatalf("count %s: %v", table, err)
}
return count
}
type pressureLuckyGiftWallet struct {
calls atomic.Int64
}
func (w *pressureLuckyGiftWallet) CreditLuckyGiftReward(_ context.Context, req *walletv1.CreditLuckyGiftRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error) {
w.calls.Add(1)
return &walletv1.CreditLuckyGiftRewardResponse{
TransactionId: "pressure_tx_" + req.GetDrawId(),
Balance: &walletv1.AssetBalance{AvailableAmount: 1_000_000},
Amount: req.GetAmount(),
GrantedAtMs: time.Now().UTC().UnixMilli(),
}, nil
}

View File

@ -102,107 +102,22 @@ func (r *Repository) ensureLuckyDrawPoolStatsTables(ctx context.Context) error {
PRIMARY KEY (app_code, pool_id, gift_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物汇总用户去重'`,
`CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_rooms (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID空值表示奖池汇总',
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, gift_id, room_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物汇总房间去重'`,
}
for _, statement := range statements {
if _, err := r.db.ExecContext(ctx, statement); err != nil {
return err
}
}
return r.backfillLuckyDrawPoolStatsIfEmpty(ctx)
}
func (r *Repository) backfillLuckyDrawPoolStatsIfEmpty(ctx context.Context) error {
var existing int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM lucky_draw_pool_stats LIMIT 1`).Scan(&existing); err != nil {
return err
}
if existing > 0 {
return nil
}
return r.backfillLuckyDrawPoolStats(ctx)
}
func (r *Repository) backfillLuckyDrawPoolStats(ctx context.Context) error {
statements := []string{
`INSERT IGNORE INTO lucky_draw_pool_stat_users (app_code, pool_id, gift_id, user_id, created_at_ms)
SELECT app_code, pool_id, '', user_id, MIN(created_at_ms)
FROM lucky_draw_records
GROUP BY app_code, pool_id, user_id`,
`INSERT IGNORE INTO lucky_draw_pool_stat_users (app_code, pool_id, gift_id, user_id, created_at_ms)
SELECT app_code, pool_id, gift_id, user_id, MIN(created_at_ms)
FROM lucky_draw_records
GROUP BY app_code, pool_id, gift_id, user_id`,
`INSERT IGNORE INTO lucky_draw_pool_stat_rooms (app_code, pool_id, gift_id, room_id, created_at_ms)
SELECT app_code, pool_id, '', room_id, MIN(created_at_ms)
FROM lucky_draw_records
GROUP BY app_code, pool_id, room_id`,
`INSERT IGNORE INTO lucky_draw_pool_stat_rooms (app_code, pool_id, gift_id, room_id, created_at_ms)
SELECT app_code, pool_id, gift_id, room_id, MIN(created_at_ms)
FROM lucky_draw_records
GROUP BY app_code, pool_id, gift_id, room_id`,
`INSERT INTO lucky_draw_pool_stats (
app_code, pool_id, gift_id, total_draws, unique_users, unique_rooms, total_spent_coins,
total_reward_coins, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
pending_draws, granted_draws, failed_draws, created_at_ms, updated_at_ms
)
SELECT app_code, pool_id, '', COUNT(*), COUNT(DISTINCT user_id), COUNT(DISTINCT room_id),
COALESCE(SUM(coin_spent), 0), COALESCE(SUM(effective_reward_coins), 0),
COALESCE(SUM(base_reward_coins), 0), COALESCE(SUM(room_atmosphere_reward_coins), 0),
COALESCE(SUM(activity_subsidy_coins), 0),
COALESCE(SUM(CASE WHEN reward_status = 'pending' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN reward_status = 'granted' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN reward_status = 'failed' THEN 1 ELSE 0 END), 0),
MIN(created_at_ms), MAX(updated_at_ms)
FROM lucky_draw_records
GROUP BY app_code, pool_id
ON DUPLICATE KEY UPDATE
total_draws = VALUES(total_draws),
unique_users = VALUES(unique_users),
unique_rooms = VALUES(unique_rooms),
total_spent_coins = VALUES(total_spent_coins),
total_reward_coins = VALUES(total_reward_coins),
base_reward_coins = VALUES(base_reward_coins),
room_atmosphere_reward_coins = VALUES(room_atmosphere_reward_coins),
activity_subsidy_coins = VALUES(activity_subsidy_coins),
pending_draws = VALUES(pending_draws),
granted_draws = VALUES(granted_draws),
failed_draws = VALUES(failed_draws),
updated_at_ms = VALUES(updated_at_ms)`,
`INSERT INTO lucky_draw_pool_stats (
app_code, pool_id, gift_id, total_draws, unique_users, unique_rooms, total_spent_coins,
total_reward_coins, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
pending_draws, granted_draws, failed_draws, created_at_ms, updated_at_ms
)
SELECT app_code, pool_id, gift_id, COUNT(*), COUNT(DISTINCT user_id), COUNT(DISTINCT room_id),
COALESCE(SUM(coin_spent), 0), COALESCE(SUM(effective_reward_coins), 0),
COALESCE(SUM(base_reward_coins), 0), COALESCE(SUM(room_atmosphere_reward_coins), 0),
COALESCE(SUM(activity_subsidy_coins), 0),
COALESCE(SUM(CASE WHEN reward_status = 'pending' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN reward_status = 'granted' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN reward_status = 'failed' THEN 1 ELSE 0 END), 0),
MIN(created_at_ms), MAX(updated_at_ms)
FROM lucky_draw_records
GROUP BY app_code, pool_id, gift_id
ON DUPLICATE KEY UPDATE
total_draws = VALUES(total_draws),
unique_users = VALUES(unique_users),
unique_rooms = VALUES(unique_rooms),
total_spent_coins = VALUES(total_spent_coins),
total_reward_coins = VALUES(total_reward_coins),
base_reward_coins = VALUES(base_reward_coins),
room_atmosphere_reward_coins = VALUES(room_atmosphere_reward_coins),
activity_subsidy_coins = VALUES(activity_subsidy_coins),
pending_draws = VALUES(pending_draws),
granted_draws = VALUES(granted_draws),
failed_draws = VALUES(failed_draws),
updated_at_ms = VALUES(updated_at_ms)`,
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID空值表示奖池汇总',
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, gift_id, room_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物汇总房间去重'`,
`CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_cursors (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
cursor_name VARCHAR(64) NOT NULL COMMENT '游标名称',
last_draw_created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最后已聚合 draw 创建时间',
last_draw_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '同一毫秒内最后已聚合 draw ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, cursor_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物后台统计快照游标'`,
}
for _, statement := range statements {
if _, err := r.db.ExecContext(ctx, statement); err != nil {
@ -279,6 +194,25 @@ func (r *Repository) ensureLuckyDrawPoolID(ctx context.Context) error {
return err
}
}
indexes := []struct {
name string
sql string
}{
{"idx_lucky_draw_created", `CREATE INDEX idx_lucky_draw_created ON lucky_draw_records (app_code, created_at_ms, draw_id)`},
{"idx_lucky_draw_pool_status_summary", `CREATE INDEX idx_lucky_draw_pool_status_summary ON lucky_draw_records (app_code, pool_id, reward_status, created_at_ms, draw_id)`},
{"idx_lucky_draw_gift_status_summary", `CREATE INDEX idx_lucky_draw_gift_status_summary ON lucky_draw_records (app_code, pool_id, gift_id, reward_status, created_at_ms, draw_id)`},
}
for _, addition := range indexes {
exists, err := r.indexExists(ctx, table, addition.name)
if err != nil {
return err
}
if !exists {
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
return err
}
}
}
return nil
}