增加幸运礼物修复和大屏

This commit is contained in:
ZuoZuo 2026-05-31 16:39:49 +08:00
parent fedd5c8f41
commit 88584f1329
32 changed files with 1187 additions and 145 deletions

View File

@ -10349,6 +10349,7 @@ type CreditLuckyGiftRewardRequest struct {
GiftId string `protobuf:"bytes,7,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"` GiftId string `protobuf:"bytes,7,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
PoolId string `protobuf:"bytes,8,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` PoolId string `protobuf:"bytes,8,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
Reason string `protobuf:"bytes,9,opt,name=reason,proto3" json:"reason,omitempty"` Reason string `protobuf:"bytes,9,opt,name=reason,proto3" json:"reason,omitempty"`
VisibleRegionId int64 `protobuf:"varint,10,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -10446,6 +10447,13 @@ func (x *CreditLuckyGiftRewardRequest) GetReason() string {
return "" return ""
} }
func (x *CreditLuckyGiftRewardRequest) GetVisibleRegionId() int64 {
if x != nil {
return x.VisibleRegionId
}
return 0
}
// CreditLuckyGiftRewardResponse 返回幸运礼物返奖入账流水和用户 COIN 账后余额。 // CreditLuckyGiftRewardResponse 返回幸运礼物返奖入账流水和用户 COIN 账后余额。
type CreditLuckyGiftRewardResponse struct { type CreditLuckyGiftRewardResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -13566,7 +13574,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" + "\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" +
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12\x16\n" + "\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12\x16\n" +
"\x06amount\x18\x03 \x01(\x03R\x06amount\x12\"\n" + "\x06amount\x18\x03 \x01(\x03R\x06amount\x12\"\n" +
"\rgranted_at_ms\x18\x04 \x01(\x03R\vgrantedAtMs\"\x92\x02\n" + "\rgranted_at_ms\x18\x04 \x01(\x03R\vgrantedAtMs\"\xbe\x02\n" +
"\x1cCreditLuckyGiftRewardRequest\x12\x1d\n" + "\x1cCreditLuckyGiftRewardRequest\x12\x1d\n" +
"\n" + "\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x19\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x19\n" +
@ -13577,7 +13585,9 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\aroom_id\x18\x06 \x01(\tR\x06roomId\x12\x17\n" + "\aroom_id\x18\x06 \x01(\tR\x06roomId\x12\x17\n" +
"\agift_id\x18\a \x01(\tR\x06giftId\x12\x17\n" + "\agift_id\x18\a \x01(\tR\x06giftId\x12\x17\n" +
"\apool_id\x18\b \x01(\tR\x06poolId\x12\x16\n" + "\apool_id\x18\b \x01(\tR\x06poolId\x12\x16\n" +
"\x06reason\x18\t \x01(\tR\x06reason\"\xbb\x01\n" + "\x06reason\x18\t \x01(\tR\x06reason\x12*\n" +
"\x11visible_region_id\x18\n" +
" \x01(\x03R\x0fvisibleRegionId\"\xbb\x01\n" +
"\x1dCreditLuckyGiftRewardResponse\x12%\n" + "\x1dCreditLuckyGiftRewardResponse\x12%\n" +
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" + "\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" +
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12\x16\n" + "\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12\x16\n" +

View File

@ -1155,6 +1155,7 @@ message CreditLuckyGiftRewardRequest {
string gift_id = 7; string gift_id = 7;
string pool_id = 8; string pool_id = 8;
string reason = 9; string reason = 9;
int64 visible_region_id = 10;
} }
// CreditLuckyGiftRewardResponse COIN // CreditLuckyGiftRewardResponse COIN

View File

@ -235,7 +235,7 @@ func main() {
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB), HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB),
Job: jobmodule.New(store, cfg, auditHandler), Job: jobmodule.New(store, cfg, auditHandler),
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler), LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), auditHandler), LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
Menu: menumodule.New(store, auditHandler), Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler), Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler), RBAC: rbacmodule.New(store, auditHandler),

View File

@ -1,6 +1,7 @@
package luckygift package luckygift
import ( import (
"context"
"math" "math"
"strings" "strings"
"time" "time"
@ -17,11 +18,15 @@ import (
type Handler struct { type Handler struct {
activity activityclient.Client activity activityclient.Client
requestTimeout time.Duration
audit shared.OperationLogger audit shared.OperationLogger
} }
func New(activity activityclient.Client, audit shared.OperationLogger) *Handler { func New(activity activityclient.Client, requestTimeout time.Duration, audit shared.OperationLogger) *Handler {
return &Handler{activity: activity, audit: audit} if requestTimeout <= 0 {
requestTimeout = 3 * time.Second
}
return &Handler{activity: activity, requestTimeout: requestTimeout, audit: audit}
} }
type configRequest struct { type configRequest struct {
@ -108,7 +113,9 @@ type drawSummaryDTO struct {
} }
func (h *Handler) GetLuckyGiftConfig(c *gin.Context) { func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
resp, err := h.activity.GetLuckyGiftConfig(c.Request.Context(), &activityv1.GetLuckyGiftConfigRequest{ ctx, cancel := h.activityContext(c)
defer cancel()
resp, err := h.activity.GetLuckyGiftConfig(ctx, &activityv1.GetLuckyGiftConfigRequest{
Meta: h.meta(c), Meta: h.meta(c),
PoolId: strings.TrimSpace(c.Query("pool_id")), PoolId: strings.TrimSpace(c.Query("pool_id")),
}) })
@ -128,7 +135,9 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
if req.PoolID == "" { if req.PoolID == "" {
req.PoolID = strings.TrimSpace(c.Query("pool_id")) req.PoolID = strings.TrimSpace(c.Query("pool_id"))
} }
resp, err := h.activity.UpsertLuckyGiftConfig(c.Request.Context(), &activityv1.UpsertLuckyGiftConfigRequest{ ctx, cancel := h.activityContext(c)
defer cancel()
resp, err := h.activity.UpsertLuckyGiftConfig(ctx, &activityv1.UpsertLuckyGiftConfigRequest{
Meta: h.meta(c), Meta: h.meta(c),
Config: configToProto(req), Config: configToProto(req),
OperatorAdminId: int64(middleware.CurrentUserID(c)), OperatorAdminId: int64(middleware.CurrentUserID(c)),
@ -143,7 +152,9 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
} }
func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) { func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) {
resp, err := h.activity.ListLuckyGiftConfigs(c.Request.Context(), &activityv1.ListLuckyGiftConfigsRequest{ ctx, cancel := h.activityContext(c)
defer cancel()
resp, err := h.activity.ListLuckyGiftConfigs(ctx, &activityv1.ListLuckyGiftConfigsRequest{
Meta: h.meta(c), Meta: h.meta(c),
}) })
if err != nil { if err != nil {
@ -159,7 +170,9 @@ func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) {
func (h *Handler) ListLuckyGiftDraws(c *gin.Context) { func (h *Handler) ListLuckyGiftDraws(c *gin.Context) {
options := shared.ListOptions(c) options := shared.ListOptions(c)
resp, err := h.activity.ListLuckyGiftDraws(c.Request.Context(), &activityv1.ListLuckyGiftDrawsRequest{ ctx, cancel := h.activityContext(c)
defer cancel()
resp, err := h.activity.ListLuckyGiftDraws(ctx, &activityv1.ListLuckyGiftDrawsRequest{
Meta: h.meta(c), Meta: h.meta(c),
PoolId: strings.TrimSpace(c.Query("pool_id")), PoolId: strings.TrimSpace(c.Query("pool_id")),
GiftId: strings.TrimSpace(c.Query("gift_id")), GiftId: strings.TrimSpace(c.Query("gift_id")),
@ -181,7 +194,9 @@ func (h *Handler) ListLuckyGiftDraws(c *gin.Context) {
} }
func (h *Handler) GetLuckyGiftDrawSummary(c *gin.Context) { func (h *Handler) GetLuckyGiftDrawSummary(c *gin.Context) {
resp, err := h.activity.GetLuckyGiftDrawSummary(c.Request.Context(), &activityv1.GetLuckyGiftDrawSummaryRequest{ ctx, cancel := h.activityContext(c)
defer cancel()
resp, err := h.activity.GetLuckyGiftDrawSummary(ctx, &activityv1.GetLuckyGiftDrawSummaryRequest{
Meta: h.meta(c), Meta: h.meta(c),
PoolId: strings.TrimSpace(c.Query("pool_id")), PoolId: strings.TrimSpace(c.Query("pool_id")),
GiftId: strings.TrimSpace(c.Query("gift_id")), GiftId: strings.TrimSpace(c.Query("gift_id")),
@ -205,6 +220,10 @@ func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
} }
} }
func (h *Handler) activityContext(c *gin.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(c.Request.Context(), h.requestTimeout)
}
func configToProto(req configRequest) *activityv1.LuckyGiftRuleConfig { func configToProto(req configRequest) *activityv1.LuckyGiftRuleConfig {
stages := make([]*activityv1.LuckyGiftRuleStage, 0, len(req.Stages)) stages := make([]*activityv1.LuckyGiftRuleStage, 0, len(req.Stages))
for _, stage := range req.Stages { for _, stage := range req.Stages {

View File

@ -21,6 +21,7 @@ lucky_gift_worker:
enabled: true enabled: true
worker_poll_interval: "1s" worker_poll_interval: "1s"
worker_batch_size: 100 worker_batch_size: 100
worker_concurrency: 4
worker_lock_ttl: "30s" worker_lock_ttl: "30s"
worker_max_retry: 8 worker_max_retry: 8
publish_timeout: "5s" publish_timeout: "5s"

View File

@ -21,6 +21,7 @@ lucky_gift_worker:
enabled: true enabled: true
worker_poll_interval: "1s" worker_poll_interval: "1s"
worker_batch_size: 100 worker_batch_size: 100
worker_concurrency: 4
worker_lock_ttl: "30s" worker_lock_ttl: "30s"
worker_max_retry: 8 worker_max_retry: 8
publish_timeout: "5s" publish_timeout: "5s"

View File

@ -21,6 +21,7 @@ lucky_gift_worker:
enabled: true enabled: true
worker_poll_interval: "1s" worker_poll_interval: "1s"
worker_batch_size: 100 worker_batch_size: 100
worker_concurrency: 4
worker_lock_ttl: "30s" worker_lock_ttl: "30s"
worker_max_retry: 8 worker_max_retry: 8
publish_timeout: "5s" publish_timeout: "5s"

View File

@ -256,6 +256,44 @@ CREATE TABLE IF NOT EXISTS lucky_draw_records (
KEY idx_lucky_draw_status (app_code, reward_status, updated_at_ms) KEY idx_lucky_draw_status (app_code, reward_status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物抽奖事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物抽奖事实';
CREATE TABLE IF NOT EXISTS lucky_draw_pool_stats (
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空值表示奖池汇总',
total_draws BIGINT NOT NULL DEFAULT 0 COMMENT '抽奖次数',
unique_users BIGINT NOT NULL DEFAULT 0 COMMENT '参与用户数',
unique_rooms BIGINT NOT NULL DEFAULT 0 COMMENT '参与房间数',
total_spent_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币',
total_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '返还金币',
base_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '基础 RTP 返奖',
room_atmosphere_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '房间气氛支出',
activity_subsidy_coins BIGINT NOT NULL DEFAULT 0 COMMENT '活动补贴支出',
pending_draws BIGINT NOT NULL DEFAULT 0 COMMENT '待发放数量',
granted_draws BIGINT NOT NULL DEFAULT 0 COMMENT '已发放数量',
failed_draws BIGINT NOT NULL DEFAULT 0 COMMENT '发放失败数量',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, gift_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物池级汇总统计';
CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_users (
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空值表示奖池汇总',
user_id BIGINT NOT NULL COMMENT '用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
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='幸运礼物汇总房间去重';
CREATE TABLE IF NOT EXISTS im_broadcast_outbox ( CREATE TABLE IF NOT EXISTS im_broadcast_outbox (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
event_id VARCHAR(128) NOT NULL COMMENT '事件幂等 ID', event_id VARCHAR(128) NOT NULL COMMENT '事件幂等 ID',

View File

@ -532,6 +532,7 @@ func luckyGiftWorkerOptions(nodeID string, cfg config.LuckyGiftWorkerConfig) luc
WorkerID: nodeID + "-lucky-gift", WorkerID: nodeID + "-lucky-gift",
PollInterval: cfg.WorkerPollInterval, PollInterval: cfg.WorkerPollInterval,
BatchSize: cfg.WorkerBatchSize, BatchSize: cfg.WorkerBatchSize,
Concurrency: cfg.WorkerConcurrency,
LockTTL: cfg.WorkerLockTTL, LockTTL: cfg.WorkerLockTTL,
MaxRetry: cfg.WorkerMaxRetry, MaxRetry: cfg.WorkerMaxRetry,
PublishTimeout: cfg.PublishTimeout, PublishTimeout: cfg.PublishTimeout,

View File

@ -72,6 +72,7 @@ type LuckyGiftWorkerConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
WorkerPollInterval time.Duration `yaml:"worker_poll_interval"` WorkerPollInterval time.Duration `yaml:"worker_poll_interval"`
WorkerBatchSize int `yaml:"worker_batch_size"` WorkerBatchSize int `yaml:"worker_batch_size"`
WorkerConcurrency int `yaml:"worker_concurrency"`
WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"` WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"`
WorkerMaxRetry int `yaml:"worker_max_retry"` WorkerMaxRetry int `yaml:"worker_max_retry"`
PublishTimeout time.Duration `yaml:"publish_timeout"` PublishTimeout time.Duration `yaml:"publish_timeout"`
@ -169,6 +170,7 @@ func Default() Config {
Enabled: false, Enabled: false,
WorkerPollInterval: time.Second, WorkerPollInterval: time.Second,
WorkerBatchSize: 100, WorkerBatchSize: 100,
WorkerConcurrency: 4,
WorkerLockTTL: 30 * time.Second, WorkerLockTTL: 30 * time.Second,
WorkerMaxRetry: 8, WorkerMaxRetry: 8,
PublishTimeout: 5 * time.Second, PublishTimeout: 5 * time.Second,
@ -297,6 +299,12 @@ func Load(path string) (Config, error) {
if cfg.LuckyGiftWorker.WorkerBatchSize <= 0 { if cfg.LuckyGiftWorker.WorkerBatchSize <= 0 {
cfg.LuckyGiftWorker.WorkerBatchSize = 100 cfg.LuckyGiftWorker.WorkerBatchSize = 100
} }
if cfg.LuckyGiftWorker.WorkerConcurrency <= 0 {
cfg.LuckyGiftWorker.WorkerConcurrency = 4
}
if cfg.LuckyGiftWorker.WorkerConcurrency > cfg.LuckyGiftWorker.WorkerBatchSize {
cfg.LuckyGiftWorker.WorkerConcurrency = cfg.LuckyGiftWorker.WorkerBatchSize
}
if cfg.LuckyGiftWorker.WorkerLockTTL <= 0 { if cfg.LuckyGiftWorker.WorkerLockTTL <= 0 {
cfg.LuckyGiftWorker.WorkerLockTTL = 30 * time.Second cfg.LuckyGiftWorker.WorkerLockTTL = 30 * time.Second
} }

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
"sync"
"time" "time"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
@ -142,6 +143,7 @@ type WorkerOptions struct {
WorkerID string WorkerID string
PollInterval time.Duration PollInterval time.Duration
BatchSize int BatchSize int
Concurrency int
LockTTL time.Duration LockTTL time.Duration
MaxRetry int MaxRetry int
PublishTimeout time.Duration PublishTimeout time.Duration
@ -174,15 +176,54 @@ func (s *Service) ProcessPendingDrawOutbox(ctx context.Context, options WorkerOp
if err != nil { if err != nil {
return 0, err return 0, err
} }
processed := 0 if len(events) == 0 {
for _, event := range events { return 0, nil
// 单条失败立即返回,让外层记录批次错误;已处理成功的事件已经各自落 delivered不会被回滚。 }
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 { if err := s.processDrawOutbox(ctx, event, options); err != nil {
return processed, err 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++ processed++
} }
return processed, nil return processed, firstErr
} }
func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox, options WorkerOptions) error { func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox, options WorkerOptions) error {
@ -209,6 +250,7 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
GiftId: payload.GiftID, GiftId: payload.GiftID,
PoolId: payload.PoolID, PoolId: payload.PoolID,
Reason: "lucky_gift_reward", Reason: "lucky_gift_reward",
VisibleRegionId: payload.VisibleRegionID,
}) })
if err != nil { if err != nil {
return s.markOutboxFailed(eventCtx, event, options, err, false) return s.markOutboxFailed(eventCtx, event, options, err, false)
@ -216,6 +258,12 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
walletTransactionID = resp.GetTransactionId() walletTransactionID = resp.GetTransactionId()
credited = true credited = true
} }
if credited {
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 { if s.publisher == nil {
// 已返奖但缺少房间发布依赖时不能把 draw 标记 failed否则会误导账务只让 outbox 保持可重试。 // 已返奖但缺少房间发布依赖时不能把 draw 标记 failed否则会误导账务只让 outbox 保持可重试。
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift room publisher is not configured"), credited) return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift room publisher is not configured"), credited)
@ -233,11 +281,11 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
return s.markOutboxFailed(eventCtx, event, options, err, credited) return s.markOutboxFailed(eventCtx, event, options, err, credited)
} }
nowMS := s.now().UTC().UnixMilli() nowMS := s.now().UTC().UnixMilli()
// 所有外部副作用都成功后再把整批 draw 从 pending 收敛到 granted。 if !credited {
// 这一步必须晚于钱包和 IM否则客户端可能看到“已发放”但余额或房间表现还没落地。
if err := s.repository.MarkLuckyGiftDrawsGranted(eventCtx, event.AppCode, payload.DrawIDs, walletTransactionID, nowMS); err != nil { if err := s.repository.MarkLuckyGiftDrawsGranted(eventCtx, event.AppCode, payload.DrawIDs, walletTransactionID, nowMS); err != nil {
return err return err
} }
}
return s.repository.MarkLuckyGiftOutboxDelivered(eventCtx, event, nowMS) return s.repository.MarkLuckyGiftOutboxDelivered(eventCtx, event, nowMS)
} }
@ -490,6 +538,12 @@ func normalizeWorkerOptions(options WorkerOptions) WorkerOptions {
if options.BatchSize > 500 { if options.BatchSize > 500 {
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 { if options.LockTTL <= 0 {
options.LockTTL = 30 * time.Second options.LockTTL = 30 * time.Second
} }

View File

@ -3,6 +3,7 @@ package luckygift
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -124,6 +125,7 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
"room_id": "room-1", "room_id": "room-1",
"gift_id": "rose", "gift_id": "rose",
"gift_count": 3, "gift_count": 3,
"visible_region_id": 210,
"coin_spent": 300, "coin_spent": 300,
"effective_reward_coins": 800, "effective_reward_coins": 800,
"multiplier_ppm": 8000000, "multiplier_ppm": 8000000,
@ -139,7 +141,8 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
} }
wallet := &fakeLuckyGiftWallet{} wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{} publisher := &fakeLuckyGiftPublisher{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher)) broadcaster := &fakeLuckyGiftRegionBroadcaster{}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(broadcaster))
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() }) service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"}) processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
@ -149,7 +152,7 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
if processed != 1 { if processed != 1 {
t.Fatalf("processed count mismatch: %d", processed) t.Fatalf("processed count mismatch: %d", processed)
} }
if wallet.last == nil || wallet.last.GetAmount() != 800 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" { if wallet.last == nil || wallet.last.GetAmount() != 800 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" || wallet.last.GetVisibleRegionId() != 210 {
t.Fatalf("wallet should receive one aggregate reward request: %+v", wallet.last) t.Fatalf("wallet should receive one aggregate reward request: %+v", wallet.last)
} }
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" { if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" {
@ -157,6 +160,54 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
} }
} }
func TestProcessPendingDrawOutboxGrantsRewardWhenRoomIMFails(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_im_fail",
"app_code": "lalu",
"draw_id": "lucky_draw_im_fail",
"command_id": "cmd-gift-im-fail",
"pool_id": "super_lucky",
"user_id": 42,
"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_lucky_draw_im_fail",
EventType: "LuckyGiftDrawn",
PayloadJSON: string(payload),
}},
}
wallet := &fakeLuckyGiftWallet{}
publisher := &fakeLuckyGiftPublisher{err: errors.New("im unavailable")}
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
if err != nil {
t.Fatalf("ProcessPendingDrawOutbox should schedule retry without failing batch: %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)
}
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)
}
}
type fakeLuckyGiftRepository struct { type fakeLuckyGiftRepository struct {
getConfigCalls int getConfigCalls int
upserted domain.RuleConfig upserted domain.RuleConfig
@ -165,6 +216,7 @@ type fakeLuckyGiftRepository struct {
grantedDrawID string grantedDrawID string
grantedDrawIDs []string grantedDrawIDs []string
deliveredOutboxID string deliveredOutboxID string
retryableOutboxID string
} }
func (r *fakeLuckyGiftRepository) GetLuckyGiftRuleConfig(context.Context, string) (domain.RuleConfig, bool, error) { func (r *fakeLuckyGiftRepository) GetLuckyGiftRuleConfig(context.Context, string) (domain.RuleConfig, bool, error) {
@ -206,7 +258,8 @@ func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxDelivered(_ context.Context
return nil return nil
} }
func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxRetryable(context.Context, domain.DrawOutbox, int, int64, string, int64) error { func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxRetryable(_ context.Context, event domain.DrawOutbox, _ int, _ int64, _ string, _ int64) error {
r.retryableOutboxID = event.OutboxID
return nil return nil
} }
@ -246,11 +299,12 @@ func (w *fakeLuckyGiftWallet) CreditLuckyGiftReward(_ context.Context, req *wall
type fakeLuckyGiftPublisher struct { type fakeLuckyGiftPublisher struct {
last tencentim.CustomGroupMessage last tencentim.CustomGroupMessage
err error
} }
func (p *fakeLuckyGiftPublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error { func (p *fakeLuckyGiftPublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error {
p.last = message p.last = message
return nil return p.err
} }
type fakeLuckyGiftRegionBroadcaster struct { type fakeLuckyGiftRegionBroadcaster struct {

View File

@ -576,6 +576,9 @@ func (r *Repository) persistLuckyBatchDraw(ctx context.Context, tx *sql.Tx, appC
if err := r.insertLuckyDrawRecords(ctx, tx, state.Records); err != nil { if err := r.insertLuckyDrawRecords(ctx, tx, state.Records); err != nil {
return err return err
} }
if err := r.incrementLuckyDrawPoolStats(ctx, tx, state.Records, nowMS); err != nil {
return err
}
if state.NeedsOutbox { if state.NeedsOutbox {
// 只有本批存在钱包/IM/反馈副作用时才产生 outbox纯 0 倍且无反馈的批次直接终态 granted。 // 只有本批存在钱包/IM/反馈副作用时才产生 outbox纯 0 倍且无反馈的批次直接终态 granted。
return r.insertLuckyAggregateDrawOutbox(ctx, tx, appCode, cmd, state, nowMS) return r.insertLuckyAggregateDrawOutbox(ctx, tx, appCode, cmd, state, nowMS)
@ -653,6 +656,143 @@ func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, rec
return nil return nil
} }
type luckyDrawPoolStatKey struct {
AppCode string
PoolID string
GiftID string
}
type luckyDrawPoolStatDelta struct {
TotalDraws int64
TotalSpentCoins int64
TotalRewardCoins int64
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 {
result, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO lucky_draw_pool_stat_users (app_code, pool_id, gift_id, user_id, created_at_ms)
VALUES (?, ?, ?, ?, ?)`,
key.AppCode, key.PoolID, key.GiftID, userID, nowMS,
)
if err != nil {
return 0, err
}
rows, _ := result.RowsAffected()
inserted += rows
}
return inserted, nil
}
func (r *Repository) insertLuckyDrawStatRooms(ctx context.Context, tx *sql.Tx, key luckyDrawPoolStatKey, roomIDs map[string]struct{}, nowMS int64) (int64, error) {
var inserted int64
for roomID := range roomIDs {
result, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO lucky_draw_pool_stat_rooms (app_code, pool_id, gift_id, room_id, created_at_ms)
VALUES (?, ?, ?, ?, ?)`,
key.AppCode, key.PoolID, key.GiftID, roomID, nowMS,
)
if err != nil {
return 0, err
}
rows, _ := result.RowsAffected()
inserted += rows
}
return inserted, nil
}
func (r *Repository) upsertLuckyDrawPoolStatDelta(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,
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
total_draws = total_draws + VALUES(total_draws),
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)`,
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,
)
return err
}
// luckyDrawRecordArgs 固定 lucky_draw_records 的列顺序;集中维护可以避免批量 INSERT 和单条 INSERT 语义漂移。 // luckyDrawRecordArgs 固定 lucky_draw_records 的列顺序;集中维护可以避免批量 INSERT 和单条 INSERT 语义漂移。
func luckyDrawRecordArgs(input luckyDrawRecordInput) []any { func luckyDrawRecordArgs(input luckyDrawRecordInput) []any {
candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(input) candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(input)
@ -1132,6 +1272,27 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
); err != nil { ); err != nil {
return err 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) { if luckyDrawNeedsOutbox(input.Candidate, input.StageFeedback) {
// outbox 是钱包入账和房间 IM 的唯一异步出口;事务内只写事实,不调用外部 RPC。 // outbox 是钱包入账和房间 IM 的唯一异步出口;事务内只写事实,不调用外部 RPC。
payload, _ := json.Marshal(map[string]any{ payload, _ := json.Marshal(map[string]any{
@ -1326,6 +1487,15 @@ func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, dra
end = len(drawIDs) end = len(drawIDs)
} }
chunk := drawIDs[start:end] chunk := drawIDs[start:end]
tx, err := r.db.BeginTx(ctx, nil)
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} args := []any{status, walletTransactionID, failureReason, nowMS, appCode}
for _, drawID := range chunk { for _, drawID := range chunk {
args = append(args, drawID) args = append(args, drawID)
@ -1333,18 +1503,117 @@ func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, dra
query := ` query := `
UPDATE lucky_draw_records UPDATE lucky_draw_records
SET reward_status = ?, reward_transaction_id = ?, reward_failure_reason = ?, updated_at_ms = ? SET reward_status = ?, reward_transaction_id = ?, reward_failure_reason = ?, updated_at_ms = ?
WHERE app_code = ? AND draw_id IN (` + luckySQLPlaceholders(len(chunk)) + `)` WHERE app_code = ? AND draw_id IN (` + luckySQLPlaceholders(len(chunk)) + `) AND reward_status <> ?`
args = append(args, status)
if onlyPending { if onlyPending {
query += ` AND reward_status = ?` query += ` AND reward_status = ?`
args = append(args, domain.StatusPending) args = append(args, domain.StatusPending)
} }
if _, err := r.db.ExecContext(ctx, query, args...); err != nil { if _, err := tx.ExecContext(ctx, query, args...); err != nil {
_ = 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 return err
} }
} }
return nil 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。 // normalizeLuckyDrawIDs 去掉空值和重复值outbox payload 是补偿输入,必须先清洗再拼 SQL IN。
func normalizeLuckyDrawIDs(drawIDs []string) []string { func normalizeLuckyDrawIDs(drawIDs []string) []string {
seen := map[string]bool{} seen := map[string]bool{}
@ -1421,6 +1690,13 @@ func (r *Repository) GetLuckyGiftDrawSummary(ctx context.Context, query domain.D
if r == nil || r.db == nil { if r == nil || r.db == nil {
return domain.DrawSummary{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") return domain.DrawSummary{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
} }
if luckyDrawSummaryCanUseStats(query) {
return r.getLuckyGiftDrawSummaryFromStats(ctx, query)
}
return r.getLuckyGiftDrawSummaryFromRecords(ctx, query)
}
func (r *Repository) getLuckyGiftDrawSummaryFromRecords(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
whereSQL, args := luckyDrawWhereClause(appcode.FromContext(ctx), query) whereSQL, args := luckyDrawWhereClause(appcode.FromContext(ctx), query)
var summary domain.DrawSummary var summary domain.DrawSummary
summary.PoolID = query.PoolID summary.PoolID = query.PoolID
@ -1459,6 +1735,45 @@ func (r *Repository) GetLuckyGiftDrawSummary(ctx context.Context, query domain.D
return summary, nil return summary, nil
} }
func luckyDrawSummaryCanUseStats(query domain.DrawQuery) bool {
return query.UserID <= 0 && strings.TrimSpace(query.RoomID) == "" && strings.TrimSpace(query.Status) == ""
}
func (r *Repository) getLuckyGiftDrawSummaryFromStats(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
var summary domain.DrawSummary
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
FROM lucky_draw_pool_stats
WHERE app_code = ? AND pool_id = ? AND gift_id = ?`,
appcode.FromContext(ctx), query.PoolID, strings.TrimSpace(query.GiftID),
).Scan(
&summary.TotalDraws,
&summary.UniqueUsers,
&summary.UniqueRooms,
&summary.TotalSpentCoins,
&summary.TotalRewardCoins,
&summary.BaseRewardCoins,
&summary.RoomAtmosphereRewardCoins,
&summary.ActivitySubsidyCoins,
&summary.PendingDraws,
&summary.GrantedDraws,
&summary.FailedDraws,
)
if errors.Is(err, sql.ErrNoRows) {
return r.getLuckyGiftDrawSummaryFromRecords(ctx, query)
}
if err != nil {
return domain.DrawSummary{}, err
}
if summary.TotalSpentCoins > 0 {
summary.ActualRTPPPM = summary.TotalRewardCoins * luckyPPMScale / summary.TotalSpentCoins
}
return summary, nil
}
func luckyDrawWhereClause(appCode string, query domain.DrawQuery) (string, []any) { func luckyDrawWhereClause(appCode string, query domain.DrawQuery) (string, []any) {
where := []string{"app_code = ?"} where := []string{"app_code = ?"}
args := []any{appCode} args := []any{appCode}

View File

@ -51,6 +51,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureLuckyDrawRewardSettlementColumns(ctx); err != nil { if err := r.ensureLuckyDrawRewardSettlementColumns(ctx); err != nil {
return err return err
} }
if err := r.ensureLuckyDrawPoolStatsTables(ctx); err != nil {
return err
}
if err := r.ensureLuckyGiftRuleVersionTables(ctx); err != nil { if err := r.ensureLuckyGiftRuleVersionTables(ctx); err != nil {
return err return err
} }
@ -63,6 +66,146 @@ func (r *Repository) Migrate(ctx context.Context) error {
return nil return nil
} }
func (r *Repository) ensureLuckyDrawPoolStatsTables(ctx context.Context) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS lucky_draw_pool_stats (
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空值表示奖池汇总',
total_draws BIGINT NOT NULL DEFAULT 0 COMMENT '抽奖次数',
unique_users BIGINT NOT NULL DEFAULT 0 COMMENT '参与用户数',
unique_rooms BIGINT NOT NULL DEFAULT 0 COMMENT '参与房间数',
total_spent_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币',
total_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '返还金币',
base_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '基础 RTP 返奖',
room_atmosphere_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '房间气氛支出',
activity_subsidy_coins BIGINT NOT NULL DEFAULT 0 COMMENT '活动补贴支出',
pending_draws BIGINT NOT NULL DEFAULT 0 COMMENT '待发放数量',
granted_draws BIGINT NOT NULL DEFAULT 0 COMMENT '已发放数量',
failed_draws BIGINT NOT NULL DEFAULT 0 COMMENT '发放失败数量',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, gift_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物池级汇总统计'`,
`CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_users (
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空值表示奖池汇总',
user_id BIGINT NOT NULL COMMENT '用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
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)`,
}
for _, statement := range statements {
if _, err := r.db.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
func (r *Repository) ensureLuckyUserStateFlowColumns(ctx context.Context) error { func (r *Repository) ensureLuckyUserStateFlowColumns(ctx context.Context) error {
// 用户阶段改按金币流水折算等价抽数,状态表必须持久保存累计流水和连续未中奖次数,不能再只依赖 paid_draws。 // 用户阶段改按金币流水折算等价抽数,状态表必须持久保存累计流水和连续未中奖次数,不能再只依赖 paid_draws。
additions := []struct { additions := []struct {

View File

@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS game_launch_sessions (
user_id BIGINT NOT NULL COMMENT '用户 ID', user_id BIGINT NOT NULL COMMENT '用户 ID',
display_user_id VARCHAR(32) NOT NULL COMMENT '展示用户 ID', display_user_id VARCHAR(32) NOT NULL COMMENT '展示用户 ID',
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID', room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户启动游戏时的区域 ID',
scene VARCHAR(64) NOT NULL COMMENT '场景', scene VARCHAR(64) NOT NULL COMMENT '场景',
platform_code VARCHAR(64) NOT NULL COMMENT '平台编码', platform_code VARCHAR(64) NOT NULL COMMENT '平台编码',
game_id VARCHAR(96) NOT NULL COMMENT '游戏 ID', game_id VARCHAR(96) NOT NULL COMMENT '游戏 ID',
@ -91,6 +92,7 @@ CREATE TABLE IF NOT EXISTS game_orders (
provider_game_id VARCHAR(128) NOT NULL COMMENT '提供方游戏 ID', provider_game_id VARCHAR(128) NOT NULL COMMENT '提供方游戏 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID', user_id BIGINT NOT NULL COMMENT '用户 ID',
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID', room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户启动游戏时的区域 ID',
op_type VARCHAR(32) NOT NULL COMMENT 'op类型', op_type VARCHAR(32) NOT NULL COMMENT 'op类型',
coin_amount BIGINT NOT NULL COMMENT '金币数量', coin_amount BIGINT NOT NULL COMMENT '金币数量',
status VARCHAR(32) NOT NULL COMMENT '业务状态', status VARCHAR(32) NOT NULL COMMENT '业务状态',

View File

@ -112,6 +112,7 @@ type LaunchSession struct {
// DisplayUserID 用于对外 uid_mode避免直接暴露内部数字 ID。 // DisplayUserID 用于对外 uid_mode避免直接暴露内部数字 ID。
DisplayUserID string DisplayUserID string
RoomID string RoomID string
RegionID int64
Scene string Scene string
PlatformCode string PlatformCode string
GameID string GameID string
@ -137,6 +138,7 @@ type GameOrder struct {
ProviderGameID string ProviderGameID string
UserID int64 UserID int64
RoomID string RoomID string
RegionID int64
// OpType 只允许钱包语义 debit/credit厂商枚举在适配器层转换。 // OpType 只允许钱包语义 debit/credit厂商枚举在适配器层转换。
OpType string OpType string
CoinAmount int64 CoinAmount int64

View File

@ -107,6 +107,7 @@ type LaunchCommand struct {
GameID string GameID string
Scene string Scene string
RoomID string RoomID string
RegionID int64
AccessToken string AccessToken string
} }
@ -188,6 +189,14 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive { if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive {
return LaunchResult{}, xerr.New(xerr.Conflict, "game is not launchable") return LaunchResult{}, xerr.New(xerr.Conflict, "game is not launchable")
} }
regionID := command.RegionID
if regionID <= 0 {
var err error
regionID, err = s.resolveUserRegion(ctx, command.AppCode, command.RequestID, command.UserID)
if err != nil {
return LaunchResult{}, err
}
}
now := s.now() now := s.now()
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6) sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
@ -216,6 +225,7 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
UserID: command.UserID, UserID: command.UserID,
DisplayUserID: command.DisplayUserID, DisplayUserID: command.DisplayUserID,
RoomID: strings.TrimSpace(command.RoomID), RoomID: strings.TrimSpace(command.RoomID),
RegionID: regionID,
Scene: command.Scene, Scene: command.Scene,
PlatformCode: game.PlatformCode, PlatformCode: game.PlatformCode,
GameID: game.GameID, GameID: game.GameID,
@ -409,6 +419,25 @@ func (s *Service) getCallbackUserInfo(ctx context.Context, app string, req *game
}, nil }, nil
} }
func (s *Service) resolveUserRegion(ctx context.Context, app string, requestID string, userID int64) (int64, error) {
if userID <= 0 || s.user == nil {
return 0, nil
}
userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{
RequestId: requestID,
Caller: "game-service",
SentAtMs: s.now().UnixMilli(),
AppCode: app,
},
UserId: userID,
})
if err != nil {
return 0, err
}
return userResp.GetUser().GetRegionId(), nil
}
func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, requestHash string) (map[string]any, error) { func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, requestHash string) (map[string]any, error) {
if s.wallet == nil { if s.wallet == nil {
return nil, xerr.New(xerr.Unavailable, "wallet client is not configured") return nil, xerr.New(xerr.Unavailable, "wallet client is not configured")
@ -439,6 +468,10 @@ func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *
} }
body.ProviderGameID = game.ProviderGameID body.ProviderGameID = game.ProviderGameID
} }
regionID, err := s.resolveUserRegion(ctx, app, req.GetMeta().GetRequestId(), body.UserID)
if err != nil {
return nil, err
}
order := gamedomain.GameOrder{ order := gamedomain.GameOrder{
AppCode: app, AppCode: app,
OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+body.ProviderOrderID), OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+body.ProviderOrderID),
@ -449,6 +482,7 @@ func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *
ProviderGameID: body.ProviderGameID, ProviderGameID: body.ProviderGameID,
UserID: body.UserID, UserID: body.UserID,
RoomID: strings.TrimSpace(body.RoomID), RoomID: strings.TrimSpace(body.RoomID),
RegionID: regionID,
OpType: body.OpType, OpType: body.OpType,
CoinAmount: body.CoinAmount, CoinAmount: body.CoinAmount,
Status: gamedomain.OrderStatusWalletApplying, Status: gamedomain.OrderStatusWalletApplying,

View File

@ -57,7 +57,7 @@ func TestLaunchGameCreatesSessionAndRejectsMaintenance(t *testing.T) {
if result.SessionID == "" || result.ExpiresAtMS != 1700000900000 || !strings.Contains(result.LaunchURL, "session_token=") { if result.SessionID == "" || result.ExpiresAtMS != 1700000900000 || !strings.Contains(result.LaunchURL, "session_token=") {
t.Fatalf("launch result mismatch: %+v", result) t.Fatalf("launch result mismatch: %+v", result)
} }
if repo.session.GameID != "demo_rocket_001" || repo.session.UserID != 42 || repo.session.LaunchTokenHash == "" { if repo.session.GameID != "demo_rocket_001" || repo.session.UserID != 42 || repo.session.RegionID != 100 || repo.session.LaunchTokenHash == "" {
t.Fatalf("launch session was not persisted: %+v", repo.session) t.Fatalf("launch session was not persisted: %+v", repo.session)
} }

View File

@ -308,6 +308,7 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game
ProviderGameID: session.ProviderGameID, ProviderGameID: session.ProviderGameID,
UserID: session.UserID, UserID: session.UserID,
RoomID: strings.TrimSpace(firstNonEmpty(roomID, session.RoomID)), RoomID: strings.TrimSpace(firstNonEmpty(roomID, session.RoomID)),
RegionID: session.RegionID,
OpType: opType, OpType: opType,
CoinAmount: amount, CoinAmount: amount,
Status: gamedomain.OrderStatusWalletApplying, Status: gamedomain.OrderStatusWalletApplying,

View File

@ -118,6 +118,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
display_user_id VARCHAR(32) NOT NULL, display_user_id VARCHAR(32) NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '', room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0,
scene VARCHAR(64) NOT NULL, scene VARCHAR(64) NOT NULL,
platform_code VARCHAR(64) NOT NULL, platform_code VARCHAR(64) NOT NULL,
game_id VARCHAR(96) NOT NULL, game_id VARCHAR(96) NOT NULL,
@ -141,6 +142,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
provider_game_id VARCHAR(128) NOT NULL, provider_game_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '', room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0,
op_type VARCHAR(32) NOT NULL, op_type VARCHAR(32) NOT NULL,
coin_amount BIGINT NOT NULL, coin_amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL,
@ -241,6 +243,12 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureColumn(ctx, "game_platforms", "adapter_type", "adapter_type VARCHAR(64) NOT NULL DEFAULT 'demo' AFTER api_base_url"); err != nil { if err := r.ensureColumn(ctx, "game_platforms", "adapter_type", "adapter_type VARCHAR(64) NOT NULL DEFAULT 'demo' AFTER api_base_url"); err != nil {
return err return err
} }
if err := r.ensureColumn(ctx, "game_launch_sessions", "region_id", "region_id BIGINT NOT NULL DEFAULT 0 AFTER room_id"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_orders", "region_id", "region_id BIGINT NOT NULL DEFAULT 0 AFTER room_id"); err != nil {
return err
}
return r.seedDefaults(ctx) return r.seedDefaults(ctx)
} }
@ -380,11 +388,11 @@ func (r *Repository) GetPlatform(ctx context.Context, appCode string, platformCo
func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error { func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`INSERT INTO game_launch_sessions ( `INSERT INTO game_launch_sessions (
app_code, session_id, user_id, display_user_id, room_id, scene, platform_code, game_id, app_code, session_id, user_id, display_user_id, room_id, region_id, scene, platform_code, game_id,
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.DisplayUserID, session.RoomID, session.Scene, appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.DisplayUserID, session.RoomID, session.RegionID,
session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status, session.Scene, session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status,
session.ExpiresAtMS, session.CreatedAtMS, session.UpdatedAtMS, session.ExpiresAtMS, session.CreatedAtMS, session.UpdatedAtMS,
) )
return err return err
@ -392,14 +400,14 @@ func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain
func (r *Repository) GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error) { func (r *Repository) GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error) {
row := r.db.QueryRowContext(ctx, row := r.db.QueryRowContext(ctx,
`SELECT app_code, session_id, user_id, display_user_id, room_id, scene, platform_code, game_id, `SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, scene, platform_code, game_id,
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
FROM game_launch_sessions FROM game_launch_sessions
WHERE app_code = ? AND launch_token_hash = ?`, WHERE app_code = ? AND launch_token_hash = ?`,
appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)), appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)),
) )
var item gamedomain.LaunchSession var item gamedomain.LaunchSession
if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid") return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid")
} }
@ -454,10 +462,10 @@ func (r *Repository) CreateGameOrder(ctx context.Context, order gamedomain.GameO
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO game_orders ( `INSERT INTO game_orders (
app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id, app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, op_type, coin_amount, status, request_hash, created_at_ms, updated_at_ms user_id, room_id, region_id, op_type, coin_amount, status, request_hash, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.AppCode, order.OrderID, order.PlatformCode, order.ProviderOrderID, order.ProviderRoundID, order.GameID, order.ProviderGameID, order.AppCode, order.OrderID, order.PlatformCode, order.ProviderOrderID, order.ProviderRoundID, order.GameID, order.ProviderGameID,
order.UserID, order.RoomID, order.OpType, order.CoinAmount, order.Status, order.RequestHash, order.CreatedAtMS, order.UpdatedAtMS, order.UserID, order.RoomID, order.RegionID, order.OpType, order.CoinAmount, order.Status, order.RequestHash, order.CreatedAtMS, order.UpdatedAtMS,
); err != nil { ); err != nil {
return gamedomain.GameOrder{}, false, err return gamedomain.GameOrder{}, false, err
} }
@ -470,7 +478,7 @@ func (r *Repository) CreateGameOrder(ctx context.Context, order gamedomain.GameO
func queryOrderForUpdate(ctx context.Context, tx *sql.Tx, appCode string, platformCode string, providerOrderID string) (gamedomain.GameOrder, bool, error) { func queryOrderForUpdate(ctx context.Context, tx *sql.Tx, appCode string, platformCode string, providerOrderID string) (gamedomain.GameOrder, bool, error) {
row := tx.QueryRowContext(ctx, row := tx.QueryRowContext(ctx,
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id, `SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after, user_id, room_id, region_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
request_hash, failure_code, failure_message, created_at_ms, updated_at_ms request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
FROM game_orders FROM game_orders
WHERE app_code = ? AND platform_code = ? AND provider_order_id = ? WHERE app_code = ? AND platform_code = ? AND provider_order_id = ?
@ -478,7 +486,7 @@ func queryOrderForUpdate(ctx context.Context, tx *sql.Tx, appCode string, platfo
appcode.Normalize(appCode), strings.TrimSpace(platformCode), strings.TrimSpace(providerOrderID), appcode.Normalize(appCode), strings.TrimSpace(platformCode), strings.TrimSpace(providerOrderID),
) )
var order gamedomain.GameOrder var order gamedomain.GameOrder
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil { if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return gamedomain.GameOrder{}, false, nil return gamedomain.GameOrder{}, false, nil
} }
@ -576,7 +584,7 @@ func (r *Repository) getRepairOrderByProvider(ctx context.Context, appCode strin
func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, orderID string) (gamedomain.GameOrder, bool, error) { func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, orderID string) (gamedomain.GameOrder, bool, error) {
row := tx.QueryRowContext(ctx, row := tx.QueryRowContext(ctx,
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id, `SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after, user_id, room_id, region_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
request_hash, failure_code, failure_message, created_at_ms, updated_at_ms request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
FROM game_orders FROM game_orders
WHERE app_code = ? AND order_id = ? WHERE app_code = ? AND order_id = ?
@ -584,7 +592,7 @@ func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, or
appcode.Normalize(appCode), strings.TrimSpace(orderID), appcode.Normalize(appCode), strings.TrimSpace(orderID),
) )
var order gamedomain.GameOrder var order gamedomain.GameOrder
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil { if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return gamedomain.GameOrder{}, false, nil return gamedomain.GameOrder{}, false, nil
} }
@ -632,6 +640,8 @@ func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
'provider_round_id', ?, 'provider_round_id', ?,
'user_id', ?, 'user_id', ?,
'room_id', ?, 'room_id', ?,
'visible_region_id', ?,
'region_id', ?,
'op_type', ?, 'op_type', ?,
'coin_amount', ?, 'coin_amount', ?,
'wallet_transaction_id', ?, 'wallet_transaction_id', ?,
@ -639,7 +649,7 @@ func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
), 'pending', ?, ?)`, ), 'pending', ?, ?)`,
order.AppCode, eventID, order.OrderID, order.UserID, order.PlatformCode, order.GameID, order.OpType, order.CoinAmount, order.AppCode, eventID, order.OrderID, order.UserID, order.PlatformCode, order.GameID, order.OpType, order.CoinAmount,
order.OrderID, order.PlatformCode, order.GameID, order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID, order.OrderID, order.PlatformCode, order.GameID, order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID,
order.UserID, order.RoomID, order.OpType, order.CoinAmount, walletTransactionID, balanceAfter, order.UserID, order.RoomID, order.RegionID, order.RegionID, order.OpType, order.CoinAmount, walletTransactionID, balanceAfter,
nowMs, nowMs, nowMs, nowMs,
) )
return err return err

View File

@ -31,6 +31,7 @@ CREATE TABLE IF NOT EXISTS stat_app_day_country (
google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 充值美元最小单位', google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 充值美元最小单位',
gift_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '礼物消耗金币', gift_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '礼物消耗金币',
lucky_gift_turnover BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物流水', lucky_gift_turnover BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物流水',
lucky_gift_payout BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物返奖',
lucky_gift_payers BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物付费去重人数', lucky_gift_payers BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物付费去重人数',
game_turnover BIGINT NOT NULL DEFAULT 0 COMMENT '游戏流水', game_turnover BIGINT NOT NULL DEFAULT 0 COMMENT '游戏流水',
game_players BIGINT NOT NULL DEFAULT 0 COMMENT '游戏参与去重人数', game_players BIGINT NOT NULL DEFAULT 0 COMMENT '游戏参与去重人数',

View File

@ -146,10 +146,17 @@ func newConsumers(cfg config.Config, repo *mysqlstorage.Repository) ([]*rocketmq
} }
if err := add(cfg.RocketMQ.WalletOutbox, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { if err := add(cfg.RocketMQ.WalletOutbox, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
event, ok, err := rechargeEvent(message.Body) event, ok, err := rechargeEvent(message.Body)
if err != nil {
return err
}
if ok {
return repo.ConsumeRecharge(appcode.WithContext(ctx, event.AppCode), event)
}
reward, ok, err := luckyGiftRewardEvent(message.Body)
if err != nil || !ok { if err != nil || !ok {
return err return err
} }
return repo.ConsumeRecharge(appcode.WithContext(ctx, event.AppCode), event) return repo.ConsumeLuckyGiftReward(appcode.WithContext(ctx, reward.AppCode), reward)
}); err != nil { }); err != nil {
shutdownConsumers(consumers) shutdownConsumers(consumers)
return nil, err return nil, err
@ -220,15 +227,37 @@ func rechargeEvent(body []byte) (mysqlstorage.RechargeEvent, bool, error) {
return mysqlstorage.RechargeEvent{}, false, nil return mysqlstorage.RechargeEvent{}, false, nil
} }
payload := mysqlstorage.DecodeJSON(message.PayloadJSON) payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
usdMinor := firstNonZeroInt64(mysqlstorage.Int64(payload, "recharge_usd_minor"), mysqlstorage.Int64(payload, "amount_micro"))
regionID := firstNonZeroInt64(mysqlstorage.Int64(payload, "target_region_id"), mysqlstorage.Int64(payload, "region_id"))
return mysqlstorage.RechargeEvent{ return mysqlstorage.RechargeEvent{
AppCode: message.AppCode, AppCode: message.AppCode,
EventID: message.EventID, EventID: message.EventID,
EventType: message.EventType, EventType: message.EventType,
UserID: message.UserID, UserID: message.UserID,
USDMinor: mysqlstorage.Int64(payload, "recharge_usd_minor"), USDMinor: usdMinor,
RechargeSequence: mysqlstorage.Int64(payload, "recharge_sequence"), RechargeSequence: mysqlstorage.Int64(payload, "recharge_sequence"),
TargetRegionID: mysqlstorage.Int64(payload, "target_region_id"), TargetRegionID: regionID,
RechargeType: firstNonEmpty(mysqlstorage.String(payload, "recharge_type"), "coin_seller_transfer"), RechargeType: firstNonEmpty(mysqlstorage.String(payload, "recharge_type"), mysqlstorage.String(payload, "channel"), mysqlstorage.String(payload, "provider"), "coin_seller_transfer"),
OccurredAtMS: message.OccurredAtMS,
}, true, nil
}
func luckyGiftRewardEvent(body []byte) (mysqlstorage.LuckyGiftRewardEvent, bool, error) {
message, err := walletmq.DecodeWalletOutboxMessage(body)
if err != nil {
return mysqlstorage.LuckyGiftRewardEvent{}, false, err
}
if message.EventType != "WalletLuckyGiftRewardCredited" {
return mysqlstorage.LuckyGiftRewardEvent{}, false, nil
}
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
return mysqlstorage.LuckyGiftRewardEvent{
AppCode: message.AppCode,
EventID: message.EventID,
EventType: message.EventType,
UserID: firstNonZeroInt64(mysqlstorage.Int64(payload, "user_id"), message.UserID),
CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "visible_region_id"), mysqlstorage.Int64(payload, "region_id"), mysqlstorage.Int64(payload, "target_region_id")),
Amount: mysqlstorage.Int64(payload, "amount"),
OccurredAtMS: message.OccurredAtMS, OccurredAtMS: message.OccurredAtMS,
}, true, nil }, true, nil
} }
@ -291,7 +320,7 @@ func gameOrderEvent(body []byte) (mysqlstorage.GameOrderEvent, bool, error) {
AppCode: message.AppCode, AppCode: message.AppCode,
EventID: message.EventID, EventID: message.EventID,
UserID: message.UserID, UserID: message.UserID,
CountryID: mysqlstorage.Int64(payload, "visible_region_id"), CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "visible_region_id"), mysqlstorage.Int64(payload, "region_id")),
PlatformCode: message.PlatformCode, PlatformCode: message.PlatformCode,
GameID: message.GameID, GameID: message.GameID,
OpType: message.OpType, OpType: message.OpType,
@ -345,3 +374,12 @@ func firstNonEmpty(values ...string) string {
} }
return "" return ""
} }
func firstNonZeroInt64(values ...int64) int64 {
for _, value := range values {
if value != 0 {
return value
}
}
return 0
}

View File

@ -0,0 +1,214 @@
package app
import (
"context"
"encoding/json"
"runtime"
"testing"
"time"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/gamemq"
"hyapp/pkg/roommq"
"hyapp/pkg/walletmq"
mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql"
)
func TestLocalStatisticsDataScreenFlow(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
schema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "../../deploy/mysql/initdb/001_statistics_service.sql"),
DatabasePrefix: "hy_stats_flow",
})
repo, err := mysqlstorage.Open(ctx, schema.DSN)
if err != nil {
t.Fatalf("open statistics repository failed: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
occurredAt := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC).UnixMilli()
dayStart := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC).UnixMilli()
const (
appCode = "lalu"
regionID = int64(210)
userID = int64(42)
)
googleRecharge := walletMessage(t, walletmq.WalletOutboxMessage{
AppCode: appCode,
EventID: "WalletRechargeRecorded:google:1",
EventType: "WalletRechargeRecorded",
TransactionID: "wallet_tx_google_1",
CommandID: "google_order_1",
UserID: userID,
PayloadJSON: mustJSON(t, map[string]any{
"amount_micro": int64(1_500_000),
"region_id": regionID,
"channel": "google",
"recharge_sequence": int64(1),
}),
OccurredAtMS: occurredAt,
})
recharge, ok, err := rechargeEvent(googleRecharge)
if err != nil || !ok {
t.Fatalf("decode google recharge failed: ok=%v err=%v", ok, err)
}
if err := repo.ConsumeRecharge(ctx, recharge); err != nil {
t.Fatalf("consume google recharge failed: %v", err)
}
roomGiftBody := roomGiftMessage(t, appCode, "room_gift_1", "room_1", occurredAt, &roomeventsv1.RoomGiftSent{
SenderUserId: userID,
TargetUserId: 99,
GiftId: "rose",
GiftCount: 10,
GiftValue: 1_000,
VisibleRegionId: regionID,
CommandId: "gift_command_1",
PoolId: "lucky_100",
})
gift, ok, err := roomGiftEvent(roomGiftBody)
if err != nil || !ok {
t.Fatalf("decode room gift failed: ok=%v err=%v", ok, err)
}
if err := repo.ConsumeRoomGift(ctx, gift); err != nil {
t.Fatalf("consume room gift failed: %v", err)
}
luckyReward := walletMessage(t, walletmq.WalletOutboxMessage{
AppCode: appCode,
EventID: "WalletLuckyGiftRewardCredited:1",
EventType: "WalletLuckyGiftRewardCredited",
TransactionID: "wallet_tx_lucky_1",
CommandID: "lucky_reward:lucky_draw_1",
UserID: userID,
PayloadJSON: mustJSON(t, map[string]any{
"user_id": userID,
"draw_id": "lucky_draw_1",
"room_id": "room_1",
"pool_id": "lucky_100",
"amount": int64(300),
"visible_region_id": regionID,
}),
OccurredAtMS: occurredAt,
})
reward, ok, err := luckyGiftRewardEvent(luckyReward)
if err != nil || !ok {
t.Fatalf("decode lucky reward failed: ok=%v err=%v", ok, err)
}
if err := repo.ConsumeLuckyGiftReward(ctx, reward); err != nil {
t.Fatalf("consume lucky reward failed: %v", err)
}
for _, message := range []gamemq.GameOutboxMessage{
gameMessage(appCode, "game_order_debit_1", userID, "debit", 700, regionID, occurredAt),
gameMessage(appCode, "game_order_credit_1", userID, "credit", 200, regionID, occurredAt),
} {
body, err := gamemq.EncodeGameOutboxMessage(message)
if err != nil {
t.Fatalf("encode game message failed: %v", err)
}
event, ok, err := gameOrderEvent(body)
if err != nil || !ok {
t.Fatalf("decode game order failed: ok=%v err=%v", ok, err)
}
if err := repo.ConsumeGameOrder(ctx, event); err != nil {
t.Fatalf("consume game order failed: %v", err)
}
}
overview, err := repo.QueryOverview(ctx, mysqlstorage.OverviewQuery{
AppCode: appCode,
StartMS: dayStart,
EndMS: dayStart + int64(24*time.Hour/time.Millisecond),
CountryID: regionID,
})
if err != nil {
t.Fatalf("query overview failed: %v", err)
}
if overview.GoogleRechargeUSDMinor != 1_500_000 || overview.RechargeUSDMinor != 1_500_000 || overview.NewUserRechargeUSDMinor != 1_500_000 {
t.Fatalf("google recharge metrics mismatch: %+v", overview)
}
if overview.LuckyGiftTurnover != 1_000 || overview.LuckyGiftPayout != 300 || overview.LuckyGiftProfit != 700 || overview.LuckyGiftPayers != 1 {
t.Fatalf("lucky gift metrics mismatch: %+v", overview)
}
if overview.GameTurnover != 700 || overview.GamePayout != 200 || overview.GameProfit != 500 || overview.GamePlayers != 1 {
t.Fatalf("game metrics mismatch: %+v", overview)
}
}
func walletMessage(t *testing.T, message walletmq.WalletOutboxMessage) []byte {
t.Helper()
body, err := walletmq.EncodeWalletOutboxMessage(message)
if err != nil {
t.Fatalf("encode wallet message failed: %v", err)
}
return body
}
func roomGiftMessage(t *testing.T, appCode string, eventID string, roomID string, occurredAt int64, gift *roomeventsv1.RoomGiftSent) []byte {
t.Helper()
giftBody, err := proto.Marshal(gift)
if err != nil {
t.Fatalf("marshal gift failed: %v", err)
}
body, err := roommq.EncodeRoomOutboxMessage(&roomeventsv1.EventEnvelope{
EventId: eventID,
RoomId: roomID,
EventType: "RoomGiftSent",
RoomVersion: 1,
OccurredAtMs: occurredAt,
Body: giftBody,
AppCode: appCode,
})
if err != nil {
t.Fatalf("encode room message failed: %v", err)
}
return body
}
func gameMessage(appCode string, orderID string, userID int64, opType string, amount int64, regionID int64, occurredAt int64) gamemq.GameOutboxMessage {
return gamemq.GameOutboxMessage{
AppCode: appCode,
EventID: "GameOrderSettled:" + orderID,
EventType: "GameOrderSettled",
OrderID: orderID,
UserID: userID,
PlatformCode: "yomi",
GameID: "fish_001",
OpType: opType,
CoinAmount: amount,
PayloadJSON: mustJSONNoT(map[string]any{
"order_id": orderID,
"platform_code": "yomi",
"game_id": "fish_001",
"user_id": userID,
"room_id": "room_1",
"visible_region_id": regionID,
"region_id": regionID,
"op_type": opType,
"coin_amount": amount,
}),
OccurredAtMS: occurredAt,
}
}
func mustJSON(t *testing.T, value map[string]any) string {
t.Helper()
body, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal json failed: %v", err)
}
return string(body)
}
func mustJSONNoT(value map[string]any) string {
body, err := json.Marshal(value)
if err != nil {
panic(err)
}
return string(body)
}

View File

@ -30,7 +30,11 @@ type Overview struct {
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"` GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
GiftCoinSpent int64 `json:"gift_coin_spent"` GiftCoinSpent int64 `json:"gift_coin_spent"`
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"` LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
LuckyGiftPayers int64 `json:"lucky_gift_payers"` LuckyGiftPayers int64 `json:"lucky_gift_payers"`
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"`
LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"`
GameTurnover int64 `json:"game_turnover"` GameTurnover int64 `json:"game_turnover"`
GamePayout int64 `json:"game_payout"` GamePayout int64 `json:"game_payout"`
GameRefund int64 `json:"game_refund"` GameRefund int64 `json:"game_refund"`
@ -83,16 +87,19 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0), COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(mifapay_recharge_usd_minor),0), COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(gift_coin_spent),0), COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(gift_coin_spent),0),
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payers),0), COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0), COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
COALESCE(SUM(game_players),0) COALESCE(SUM(game_players),0)
FROM stat_app_day_country FROM stat_app_day_country
WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+countryFilter, args...) WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+countryFilter, args...)
var overview Overview var overview Overview
overview.AppCode, overview.StartMS, overview.EndMS, overview.CountryID = app, query.StartMS, query.EndMS, query.CountryID overview.AppCode, overview.StartMS, overview.EndMS, overview.CountryID = app, query.StartMS, query.EndMS, query.CountryID
if err := row.Scan(&overview.NewUsers, &overview.ActiveUsers, &overview.PaidUsers, &overview.RechargeUSDMinor, &overview.NewUserRechargeUSDMinor, &overview.CoinSellerRechargeUSDMinor, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.GiftCoinSpent, &overview.LuckyGiftTurnover, &overview.LuckyGiftPayers, &overview.GameTurnover, &overview.GamePayout, &overview.GameRefund, &overview.GamePlayers); err != nil { if err := row.Scan(&overview.NewUsers, &overview.ActiveUsers, &overview.PaidUsers, &overview.RechargeUSDMinor, &overview.NewUserRechargeUSDMinor, &overview.CoinSellerRechargeUSDMinor, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.GiftCoinSpent, &overview.LuckyGiftTurnover, &overview.LuckyGiftPayout, &overview.LuckyGiftPayers, &overview.GameTurnover, &overview.GamePayout, &overview.GameRefund, &overview.GamePlayers); err != nil {
return Overview{}, err return Overview{}, err
} }
overview.LuckyGiftProfit = overview.LuckyGiftTurnover - overview.LuckyGiftPayout
overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, overview.LuckyGiftTurnover)
overview.LuckyGiftProfitRate = ratio(overview.LuckyGiftProfit, overview.LuckyGiftTurnover)
overview.GameProfit = overview.GameTurnover - overview.GamePayout - overview.GameRefund overview.GameProfit = overview.GameTurnover - overview.GamePayout - overview.GameRefund
overview.GameProfitRate = ratio(overview.GameProfit, overview.GameTurnover) overview.GameProfitRate = ratio(overview.GameProfit, overview.GameTurnover)
overview.ARPUUSDMinor = div(overview.RechargeUSDMinor, overview.ActiveUsers) overview.ARPUUSDMinor = div(overview.RechargeUSDMinor, overview.ActiveUsers)

View File

@ -75,7 +75,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
recharge_usd_minor BIGINT NOT NULL DEFAULT 0, new_user_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, recharge_usd_minor BIGINT NOT NULL DEFAULT 0, new_user_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, mifapay_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, mifapay_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, gift_coin_spent BIGINT NOT NULL DEFAULT 0, google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, gift_coin_spent BIGINT NOT NULL DEFAULT 0,
lucky_gift_turnover BIGINT NOT NULL DEFAULT 0, lucky_gift_payers BIGINT NOT NULL DEFAULT 0, lucky_gift_turnover BIGINT NOT NULL DEFAULT 0, lucky_gift_payout BIGINT NOT NULL DEFAULT 0, lucky_gift_payers BIGINT NOT NULL DEFAULT 0,
game_turnover BIGINT NOT NULL DEFAULT 0, game_players BIGINT NOT NULL DEFAULT 0, game_turnover BIGINT NOT NULL DEFAULT 0, game_players BIGINT NOT NULL DEFAULT 0,
game_payout BIGINT NOT NULL DEFAULT 0, game_refund BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, game_payout BIGINT NOT NULL DEFAULT 0, game_refund BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_day, country_id) PRIMARY KEY (app_code, stat_day, country_id)
@ -124,6 +124,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
alterStatements := []string{ alterStatements := []string{
`ALTER TABLE stat_app_day_country ADD COLUMN paid_users BIGINT NOT NULL DEFAULT 0 AFTER active_users`, `ALTER TABLE stat_app_day_country ADD COLUMN paid_users BIGINT NOT NULL DEFAULT 0 AFTER active_users`,
`ALTER TABLE stat_app_day_country ADD COLUMN game_refund BIGINT NOT NULL DEFAULT 0 AFTER game_payout`, `ALTER TABLE stat_app_day_country ADD COLUMN game_refund BIGINT NOT NULL DEFAULT 0 AFTER game_payout`,
`ALTER TABLE stat_app_day_country ADD COLUMN lucky_gift_payout BIGINT NOT NULL DEFAULT 0 AFTER lucky_gift_turnover`,
`ALTER TABLE stat_game_day_country ADD COLUMN refund_coin BIGINT NOT NULL DEFAULT 0 AFTER payout_coin`, `ALTER TABLE stat_game_day_country ADD COLUMN refund_coin BIGINT NOT NULL DEFAULT 0 AFTER payout_coin`,
} }
for _, statement := range alterStatements { for _, statement := range alterStatements {
@ -164,6 +165,16 @@ type RoomGiftEvent struct {
OccurredAtMS int64 OccurredAtMS int64
} }
type LuckyGiftRewardEvent struct {
AppCode string
EventID string
EventType string
UserID int64
CountryID int64
Amount int64
OccurredAtMS int64
}
type UserActiveEvent struct { type UserActiveEvent struct {
AppCode string AppCode string
EventID string EventID string
@ -227,7 +238,7 @@ func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) e
} }
coinSellerUSD, mifapayUSD, googleUSD := int64(0), int64(0), int64(0) coinSellerUSD, mifapayUSD, googleUSD := int64(0), int64(0), int64(0)
switch strings.ToLower(strings.TrimSpace(event.RechargeType)) { switch strings.ToLower(strings.TrimSpace(event.RechargeType)) {
case "google": case "google", "google_play":
googleUSD = event.USDMinor googleUSD = event.USDMinor
case "mifapay": case "mifapay":
mifapayUSD = event.USDMinor mifapayUSD = event.USDMinor
@ -278,6 +289,22 @@ func (r *Repository) ConsumeRoomGift(ctx context.Context, event RoomGiftEvent) e
}) })
} }
func (r *Repository) ConsumeLuckyGiftReward(ctx context.Context, event LuckyGiftRewardEvent) error {
return r.withEvent(ctx, SourceWallet, event.EventID, "WalletLuckyGiftRewardCredited", func(tx *sql.Tx, nowMS int64) error {
day := statDay(event.OccurredAtMS)
countryID := normalizeID(event.CountryID)
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, nowMS); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `
UPDATE stat_app_day_country
SET lucky_gift_payout = lucky_gift_payout + ?, updated_at_ms = ?
WHERE app_code = ? AND stat_day = ? AND country_id = ?
`, event.Amount, nowMS, appcode.Normalize(event.AppCode), day, countryID)
return err
})
}
func (r *Repository) ConsumeUserActive(ctx context.Context, event UserActiveEvent) error { func (r *Repository) ConsumeUserActive(ctx context.Context, event UserActiveEvent) error {
return r.withEvent(ctx, SourceRoom, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error { return r.withEvent(ctx, SourceRoom, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
return applyActive(ctx, tx, event.AppCode, statDay(event.OccurredAtMS), normalizeID(event.CountryID), event.UserID, event.OccurredAtMS, nowMS) return applyActive(ctx, tx, event.AppCode, statDay(event.OccurredAtMS), normalizeID(event.CountryID), event.UserID, event.OccurredAtMS, nowMS)

View File

@ -28,9 +28,9 @@ func (s *Service) QuickCreateAccount(ctx context.Context, password string, regis
// refresh session 必须绑定设备,平台也会进入注册来源和风控审计。 // refresh session 必须绑定设备,平台也会进入注册来源和风控审计。
return authdomain.Token{}, xerr.New(xerr.InvalidArgument, "device_id and platform are required") return authdomain.Token{}, xerr.New(xerr.InvalidArgument, "device_id and platform are required")
} }
if registration.Username == "" || registration.Avatar == "" || registration.Gender == "" || registration.Country == "" { if err := validateThirdPartyRegistrationRequiredForCreate(registration); err != nil {
// 快捷账号直接跳过 onboarding因此必须一次性写齐客户端准入所需的最小资料。 // 快捷账号直接跳过 onboarding因此必须一次性写齐客户端准入所需的最小资料。
return authdomain.Token{}, xerr.New(xerr.InvalidArgument, "username, avatar, gender and country are required") return authdomain.Token{}, err
} }
if err := validateThirdPartyRegistration(registration); err != nil { if err := validateThirdPartyRegistration(registration); err != nil {
return authdomain.Token{}, err return authdomain.Token{}, err

View File

@ -126,8 +126,15 @@ func newAuthService(repository *mysqltest.Repository, now *time.Time, ids []int6
} }
func thirdPartyRegistration(platform string) authdomain.ThirdPartyRegistration { func thirdPartyRegistration(platform string) authdomain.ThirdPartyRegistration {
// 三方登录是注册入口,测试默认同时提供设备和平台,避免生成缺失注册上下文的用户。 // 三方登录是注册入口,测试默认提供完整最小资料,避免生成后台不可运营的空资料用户。
return authdomain.ThirdPartyRegistration{DeviceID: "dev-" + platform, Platform: platform} return authdomain.ThirdPartyRegistration{
Username: "hy",
Avatar: "https://cdn.example/avatar.png",
Gender: "male",
Country: "SG",
DeviceID: "dev-" + platform,
Platform: platform,
}
} }
func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) userdomain.Country { func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) userdomain.Country {
@ -174,7 +181,7 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
// 覆盖三方注册、设置密码、密码登录、refresh 轮换和 logout 的完整认证生命周期。 // 覆盖三方注册、设置密码、密码登录、refresh 轮换和 logout 的完整认证生命周期。
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN") seedCountry(t, repository, "SG")
now := time.UnixMilli(1000) now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}) svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})
@ -185,8 +192,8 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
if !isNewUser || thirdToken.UserID != 900001 || thirdToken.DisplayUserID != "163000" || thirdToken.RefreshToken == "" || thirdToken.AccessToken == "" { if !isNewUser || thirdToken.UserID != 900001 || thirdToken.DisplayUserID != "163000" || thirdToken.RefreshToken == "" || thirdToken.AccessToken == "" {
t.Fatalf("unexpected third-party token: token=%+v isNew=%v", thirdToken, isNewUser) t.Fatalf("unexpected third-party token: token=%+v isNew=%v", thirdToken, isNewUser)
} }
if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) { if !thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
t.Fatalf("new third-party user must require onboarding: %+v", thirdToken) t.Fatalf("new third-party user must be completed: %+v", thirdToken)
} }
if _, err := svc.LoginPassword(ctx, "163000", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { if _, err := svc.LoginPassword(ctx, "163000", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
@ -287,25 +294,19 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
} }
func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) { func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) {
// gateway profile gate 依赖 access token 快照;refresh 必须在用户完成注册后带出最新状态。 // gateway profile gate 依赖 access token 快照;注册资料齐全的新用户必须直接带出完成态。
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG") seedCountry(t, repository, "SG")
now := time.UnixMilli(1000) now := time.UnixMilli(1000)
authSvc := newAuthService(repository, &now, []int64{900009}, []string{"100009"}) authSvc := newAuthService(repository, &now, []int64{900009}, []string{"100009"})
userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"}) token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"})
if err != nil { if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err) t.Fatalf("LoginThirdParty failed: %v", err)
} }
if token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) { if !token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
t.Fatalf("new token should require onboarding: %+v", token) t.Fatalf("new token should be completed: %+v", token)
}
now = time.UnixMilli(2000)
if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG", "", ""); err != nil {
t.Fatalf("CompleteOnboarding failed: %v", err)
} }
now = time.UnixMilli(3000) now = time.UnixMilli(3000)
@ -322,6 +323,7 @@ func TestLoginIPRiskWorkerRevokesBlockedCountrySession(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN") seedCountry(t, repository, "CN")
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000) now := time.UnixMilli(1000)
cache := newMemoryDecisionCache() cache := newMemoryDecisionCache()
authSvc := newAuthService(repository, &now, []int64{900020}, []string{"100020"}, authSvc := newAuthService(repository, &now, []int64{900020}, []string{"100020"},
@ -392,6 +394,7 @@ func TestLoginIPRiskWorkerUsesDynamicCountryBlocks(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "MY") seedCountry(t, repository, "MY")
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000) now := time.UnixMilli(1000)
if _, err := repository.RawDB().ExecContext(ctx, ` if _, err := repository.RawDB().ExecContext(ctx, `
INSERT INTO login_risk_country_blocks ( INSERT INTO login_risk_country_blocks (
@ -444,22 +447,17 @@ func TestLoginIPRiskWorkerUsesDynamicCountryBlocks(t *testing.T) {
} }
func TestIssueAccessTokenForSessionCarriesCompletedOnboardingStatusWithoutRotatingRefresh(t *testing.T) { func TestIssueAccessTokenForSessionCarriesCompletedOnboardingStatusWithoutRotatingRefresh(t *testing.T) {
// CompleteOnboarding 后只需要替换 access tokenrefresh token 原文不可从数据库反查,也不应被静默轮换。 // 重新签发 access token 只能复用已有 sessionrefresh token 原文不可从数据库反查,也不应被静默轮换。
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG") seedCountry(t, repository, "SG")
now := time.UnixMilli(1000) now := time.UnixMilli(1000)
authSvc := newAuthService(repository, &now, []int64{900010}, []string{"100010"}) authSvc := newAuthService(repository, &now, []int64{900010}, []string{"100010"})
userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding-resign", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"}) token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding-resign", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"})
if err != nil { if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err) t.Fatalf("LoginThirdParty failed: %v", err)
} }
now = time.UnixMilli(2000)
if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG", "", ""); err != nil {
t.Fatalf("CompleteOnboarding failed: %v", err)
}
now = time.UnixMilli(3000) now = time.UnixMilli(3000)
reissued, err := authSvc.IssueAccessTokenForSession(ctx, token.UserID, token.SessionID, authservice.Meta{RequestID: "req-resign"}) reissued, err := authSvc.IssueAccessTokenForSession(ctx, token.UserID, token.SessionID, authservice.Meta{RequestID: "req-resign"})
@ -479,6 +477,7 @@ func TestDisabledUserCannotLogin(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN") seedCountry(t, repository, "CN")
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000) now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}) svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})
@ -507,6 +506,7 @@ func TestThirdPartyLoginOrRegister(t *testing.T) {
// 首次三方登录创建用户,再次同 provider subject 登录应复用原用户。 // 首次三方登录创建用户,再次同 provider subject 登录应复用原用户。
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000) now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900002}, []string{"100002"}) svc := newAuthService(repository, &now, []int64{900002}, []string{"100002"})
@ -517,8 +517,11 @@ func TestThirdPartyLoginOrRegister(t *testing.T) {
if !isNewUser || firstToken.UserID != 900002 || firstToken.DisplayUserID != "163000" { if !isNewUser || firstToken.UserID != 900002 || firstToken.DisplayUserID != "163000" {
t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser) t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser)
} }
if !firstToken.ProfileCompleted || firstToken.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
t.Fatalf("first third-party registration must complete profile: %+v", firstToken)
}
secondToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third-second"}) secondToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", authdomain.ThirdPartyRegistration{DeviceID: "dev-ios", Platform: "ios"}, authservice.Meta{RequestID: "req-third-second"})
if err != nil { if err != nil {
t.Fatalf("second LoginThirdParty failed: %v", err) t.Fatalf("second LoginThirdParty failed: %v", err)
} }
@ -532,6 +535,32 @@ func TestThirdPartyLoginOrRegister(t *testing.T) {
} }
} }
func TestThirdPartyRegisterRequiresProfileFields(t *testing.T) {
// 三方首次注册必须一次性写齐后台列表和业务准入依赖的最小资料,不能再落 profile_required 空用户。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900012, 900013, 900014, 900015}, []string{"100012", "100013", "100014", "100015"})
base := thirdPartyRegistration("ios")
cases := []struct {
name string
registration authdomain.ThirdPartyRegistration
}{
{name: "username", registration: authdomain.ThirdPartyRegistration{Avatar: base.Avatar, Gender: base.Gender, Country: base.Country, DeviceID: base.DeviceID, Platform: base.Platform}},
{name: "country", registration: authdomain.ThirdPartyRegistration{Username: base.Username, Avatar: base.Avatar, Gender: base.Gender, DeviceID: base.DeviceID, Platform: base.Platform}},
{name: "gender", registration: authdomain.ThirdPartyRegistration{Username: base.Username, Avatar: base.Avatar, Country: base.Country, DeviceID: base.DeviceID, Platform: base.Platform}},
{name: "avatar", registration: authdomain.ThirdPartyRegistration{Username: base.Username, Gender: base.Gender, Country: base.Country, DeviceID: base.DeviceID, Platform: base.Platform}},
}
for _, tc := range cases {
_, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-missing-"+tc.name, tc.registration, authservice.Meta{})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT for missing %s, got %v", tc.name, err)
}
}
}
func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) { func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) {
// 三方首次注册要把资料字段和注册来源快照随 users 主记录同事务写入。 // 三方首次注册要把资料字段和注册来源快照随 users 主记录同事务写入。
ctx := context.Background() ctx := context.Background()
@ -624,6 +653,9 @@ func TestThirdPartyRegisterAssignsRegionAndExistingLoginKeepsOriginalCountry(t *
svc := newAuthService(repository, &now, []int64{900006}, []string{"100006"}) svc := newAuthService(repository, &now, []int64{900006}, []string{"100006"})
firstToken, isNew, err := svc.LoginThirdParty(ctx, "wechat", "openid-region", authdomain.ThirdPartyRegistration{ firstToken, isNew, err := svc.LoginThirdParty(ctx, "wechat", "openid-region", authdomain.ThirdPartyRegistration{
Username: "hy",
Avatar: "https://cdn.example/avatar.png",
Gender: "male",
DeviceID: "ios", DeviceID: "ios",
Platform: "ios", Platform: "ios",
Country: " sg ", Country: " sg ",
@ -756,6 +788,7 @@ func TestThirdPartyRegisterRetriesDisplayUserIDConflict(t *testing.T) {
// 默认短号候选冲突时,三方注册应在有限次数内重试下一个候选。 // 默认短号候选冲突时,三方注册应在有限次数内重试下一个候选。
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
repository.PutUser(userdomain.User{UserID: 1, CurrentDisplayUserID: "163000", Status: userdomain.StatusActive}) repository.PutUser(userdomain.User{UserID: 1, CurrentDisplayUserID: "163000", Status: userdomain.StatusActive})
now := time.UnixMilli(1000) now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900001, 900002}, []string{"100001", "100002"}) svc := newAuthService(repository, &now, []int64{900001, 900002}, []string{"100001", "100002"})
@ -779,6 +812,7 @@ func TestPasswordLoginUsesCurrentDisplayUserIDWithPrettyLease(t *testing.T) {
// 密码登录必须使用当前 active display_user_id靓号 active 时默认号不能登录。 // 密码登录必须使用当前 active display_user_id靓号 active 时默认号不能登录。
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000) now := time.UnixMilli(1000)
authSvc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}) authSvc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})
userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now })) userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))

View File

@ -83,6 +83,10 @@ func (s *Service) LoginThirdParty(ctx context.Context, provider string, credenti
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err))) s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err return authdomain.Token{}, false, err
} }
if err := validateThirdPartyRegistrationRequiredForCreate(registration); err != nil {
// 只有首次创建用户才要求注册资料完整;已有三方绑定登录不能被请求体资料缺失阻断。
return authdomain.Token{}, false, err
}
registration, err = s.resolveRegistrationCountry(ctx, registration) registration, err = s.resolveRegistrationCountry(ctx, registration)
if err != nil { if err != nil {
return authdomain.Token{}, false, err return authdomain.Token{}, false, err
@ -129,6 +133,9 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
// 三方首次登录要同时生成 user_id 和默认短号,短号冲突时有限重试。 // 三方首次登录要同时生成 user_id 和默认短号,短号冲突时有限重试。
user, displayIdentity := s.newUserWithIdentity(attempt) user, displayIdentity := s.newUserWithIdentity(attempt)
user = applyThirdPartyRegistration(user, registration) user = applyThirdPartyRegistration(user, registration)
user.ProfileCompleted = true
user.ProfileCompletedAtMs = user.CreatedAtMs
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
displayUserID, err := s.authRepository.AllocateDisplayUserID(ctx, registration.AppCode) displayUserID, err := s.authRepository.AllocateDisplayUserID(ctx, registration.AppCode)
if err != nil { if err != nil {
return authdomain.Token{}, false, err return authdomain.Token{}, false, err
@ -292,6 +299,15 @@ func validateThirdPartyRegistration(registration authdomain.ThirdPartyRegistrati
return nil return nil
} }
func validateThirdPartyRegistrationRequiredForCreate(registration authdomain.ThirdPartyRegistration) error {
if registration.Username == "" || registration.Country == "" || registration.Gender == "" || registration.Avatar == "" {
// 注册即落用户主数据;最小公开资料不完整时拒绝创建,避免后台出现空昵称、空头像或 GLOBAL 国家用户。
return xerr.New(xerr.InvalidArgument, "username, country, gender and avatar are required")
}
return nil
}
func validateRegistrationLengths(registration authdomain.ThirdPartyRegistration) error { func validateRegistrationLengths(registration authdomain.ThirdPartyRegistration) error {
checks := []struct { checks := []struct {
name string name string

View File

@ -157,6 +157,10 @@ func TestCompleteOnboardingReturnsReplacementAccessToken(t *testing.T) {
Meta: &userv1.RequestMeta{RequestId: "req-login", DeviceId: "ios"}, Meta: &userv1.RequestMeta{RequestId: "req-login", DeviceId: "ios"},
Provider: "wechat", Provider: "wechat",
Credential: "openid-complete-token", Credential: "openid-complete-token",
Username: "hy",
Avatar: "https://cdn.example/a.png",
Gender: "male",
Country: "SG",
DeviceId: "ios", DeviceId: "ios",
Platform: "ios", Platform: "ios",
}) })

View File

@ -635,6 +635,7 @@ type LuckyGiftRewardCommand struct {
Amount int64 Amount int64
DrawID string DrawID string
RoomID string RoomID string
VisibleRegionID int64
GiftID string GiftID string
PoolID string PoolID string
Reason string Reason string

View File

@ -599,6 +599,7 @@ func (r *Repository) CreditLuckyGiftReward(ctx context.Context, command ledger.L
Amount: command.Amount, Amount: command.Amount,
DrawID: command.DrawID, DrawID: command.DrawID,
RoomID: command.RoomID, RoomID: command.RoomID,
VisibleRegionID: command.VisibleRegionID,
GiftID: command.GiftID, GiftID: command.GiftID,
PoolID: command.PoolID, PoolID: command.PoolID,
Reason: command.Reason, Reason: command.Reason,
@ -1800,6 +1801,7 @@ type luckyGiftRewardMetadata struct {
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
DrawID string `json:"draw_id"` DrawID string `json:"draw_id"`
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
VisibleRegionID int64 `json:"visible_region_id"`
GiftID string `json:"gift_id"` GiftID string `json:"gift_id"`
PoolID string `json:"pool_id"` PoolID string `json:"pool_id"`
Reason string `json:"reason"` Reason string `json:"reason"`
@ -2145,6 +2147,8 @@ func luckyGiftRewardCreditedEvent(transactionID string, commandID string, metada
"user_id": metadata.TargetUserID, "user_id": metadata.TargetUserID,
"draw_id": metadata.DrawID, "draw_id": metadata.DrawID,
"room_id": metadata.RoomID, "room_id": metadata.RoomID,
"visible_region_id": metadata.VisibleRegionID,
"region_id": metadata.VisibleRegionID,
"gift_id": metadata.GiftID, "gift_id": metadata.GiftID,
"pool_id": metadata.PoolID, "pool_id": metadata.PoolID,
"amount": metadata.Amount, "amount": metadata.Amount,

View File

@ -667,6 +667,7 @@ func (s *Server) CreditLuckyGiftReward(ctx context.Context, req *walletv1.Credit
Amount: req.GetAmount(), Amount: req.GetAmount(),
DrawID: req.GetDrawId(), DrawID: req.GetDrawId(),
RoomID: req.GetRoomId(), RoomID: req.GetRoomId(),
VisibleRegionID: req.GetVisibleRegionId(),
GiftID: req.GetGiftId(), GiftID: req.GetGiftId(),
PoolID: req.GetPoolId(), PoolID: req.GetPoolId(),
Reason: req.GetReason(), Reason: req.GetReason(),