820 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package luckygift
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
// Repository 是幸运礼物唯一持久化边界RTP、单池余额、抽奖记录和返奖 outbox 必须在同一个 MySQL 事务内修改。
type Repository interface {
GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error)
PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error)
ListLuckyGiftRuleConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error)
CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error)
ExecuteExternalGiftDraw(ctx context.Context, cmd domain.ExternalDrawCommand, nowMS int64) (domain.ExternalDrawResult, error)
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
ListLuckyGiftPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error)
GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error)
ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.DrawOutbox, error)
MarkLuckyGiftOutboxDelivered(ctx context.Context, event domain.DrawOutbox, nowMS int64) error
MarkLuckyGiftOutboxRetryable(ctx context.Context, event domain.DrawOutbox, retryCount int, nextRetryAtMS int64, lastErr string, nowMS int64) error
MarkLuckyGiftOutboxFailed(ctx context.Context, event domain.DrawOutbox, retryCount int, lastErr string, nowMS int64) error
RefreshLuckyGiftAdminStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error)
RefreshLuckyGiftDatabiStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error)
MarkLuckyGiftDrawGranted(ctx context.Context, appCode string, drawID string, walletTransactionID string, nowMS int64) error
MarkLuckyGiftDrawFailed(ctx context.Context, appCode string, drawID string, failureReason string, nowMS int64) error
// 批量抽奖只产生一条聚合 outboxworker 处理后需要用 draw_ids 一次性收敛所有明细状态。
MarkLuckyGiftDrawsGranted(ctx context.Context, appCode string, drawIDs []string, walletTransactionID string, nowMS int64) error
MarkLuckyGiftDrawsFailed(ctx context.Context, appCode string, drawIDs []string, failureReason string, nowMS int64) error
}
// WalletClient 是幸运礼物返奖唯一账务依赖wallet-service 仍负责持久 outboxactivity 只做成功落库后的低延迟余额 IM。
type WalletClient interface {
CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error)
}
// UserPublisher 负责把钱包余额变化私有消息发给中奖用户;失败不能回滚已经成功的钱包入账。
type UserPublisher interface {
PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
}
// Service 承载幸运礼物查询、抽奖命令和后台规则配置。
type Service struct {
repository Repository
wallet WalletClient
userPub UserPublisher
now func() time.Time
}
type Option func(*Service)
func WithWallet(client WalletClient) Option {
return func(s *Service) {
s.wallet = client
}
}
func WithUserPublisher(publisher UserPublisher) Option {
return func(s *Service) {
s.userPub = publisher
}
}
func New(repository Repository, options ...Option) *Service {
service := &Service{repository: repository, now: time.Now}
for _, option := range options {
if option != nil {
option(service)
}
}
return service
}
func (s *Service) SetClock(now func() time.Time) {
if now != nil {
s.now = now
}
}
func (s *Service) Check(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error) {
if err := s.requireRepository(); err != nil {
return domain.CheckResult{}, err
}
// Check 是送礼前拦截,不产生抽奖事实;这里只归一化入参,最终启停仍以仓储中的当前规则为准。
cmd.PoolID = normalizePoolID(cmd.PoolID)
cmd.GiftID = strings.TrimSpace(cmd.GiftID)
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
if cmd.GiftID == "" || cmd.UserID <= 0 || cmd.RoomID == "" {
return domain.CheckResult{}, xerr.New(xerr.InvalidArgument, "lucky gift check command is incomplete")
}
return s.repository.CheckLuckyGift(ctx, cmd)
}
func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.DrawResult, error) {
if err := s.requireRepository(); err != nil {
return domain.DrawResult{}, err
}
normalized, err := s.normalizeDrawCommand(cmd)
if err != nil {
return domain.DrawResult{}, err
}
result, err := s.repository.ExecuteLuckyGiftDraw(ctx, normalized, s.now().UTC().UnixMilli())
if err != nil {
return domain.DrawResult{}, err
}
return s.creditLuckyGiftRewardFastPath(ctx, normalized, result), nil
}
func (s *Service) DrawBatch(ctx context.Context, cmds []domain.DrawCommand) ([]domain.DrawResult, error) {
if err := s.requireRepository(); err != nil {
return nil, err
}
if len(cmds) == 0 {
return nil, xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required")
}
normalized := make([]domain.DrawCommand, 0, len(cmds))
for _, cmd := range cmds {
item, err := s.normalizeDrawCommand(cmd)
if err != nil {
return nil, err
}
normalized = append(normalized, item)
}
// 批量送礼的返奖不能阻塞 room-service 主链路repository 只落 draw fact 和 settlement outboxworker 后续负责 wallet/IM。
return s.repository.ExecuteLuckyGiftDrawBatch(ctx, normalized, s.now().UTC().UnixMilli())
}
func (s *Service) ExecuteExternalDraw(ctx context.Context, cmd domain.ExternalDrawCommand) (domain.ExternalDrawResult, error) {
if err := s.requireRepository(); err != nil {
return domain.ExternalDrawResult{}, err
}
normalized, err := s.normalizeExternalDrawCommand(cmd)
if err != nil {
return domain.ExternalDrawResult{}, err
}
// 外部抽奖没有 HyApp wallet 返奖补偿repository 在同一个事务里完成 app 级幂等命中、规则版本读取、随机抽奖、记录和 outbox。
return s.repository.ExecuteExternalGiftDraw(ctx, normalized, s.now().UTC().UnixMilli())
}
func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (domain.ExternalDrawCommand, error) {
cmd.AppCode = appcode.Normalize(cmd.AppCode)
cmd.ExternalUserID = strings.TrimSpace(cmd.ExternalUserID)
cmd.RequestID = strings.TrimSpace(cmd.RequestID)
cmd.Currency = strings.ToUpper(strings.TrimSpace(cmd.Currency))
cmd.MetadataJSON = strings.TrimSpace(cmd.MetadataJSON)
cmd.PoolID = normalizePoolID(cmd.PoolID)
if cmd.AppCode == "" || cmd.ExternalUserID == "" || cmd.RequestID == "" {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is incomplete")
}
if len(cmd.AppCode) > 32 || len(cmd.ExternalUserID) > 128 || len(cmd.RequestID) > 128 || len(cmd.PoolID) > 96 {
// 外部请求的 app 级业务键会进入唯一键和审计列;长度先在服务层失败,避免事务中途暴露 MySQL truncation/too long 错误。
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is too long")
}
if cmd.GiftCount <= 0 || cmd.UnitAmount <= 0 || cmd.TotalAmount <= 0 {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift amount must be positive")
}
if cmd.GiftCount > int64(^uint32(0)>>1) {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift count is too large")
}
if cmd.GiftCount > (1<<63-1)/cmd.UnitAmount || cmd.GiftCount*cmd.UnitAmount != cmd.TotalAmount {
// 金额校验必须在 owner service 再做一遍gateway 是外部入口,不能成为唯一可信校验点。
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift total_amount mismatch")
}
if cmd.Currency == "" {
cmd.Currency = "COIN"
}
if len(cmd.Currency) > 16 {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift currency is too long")
}
if cmd.Currency != "COIN" {
// lucky-gift-service 当前所有 RTP、奖池和返奖审计都以金币等价单位结算没有 app 汇率规则前不能接收任意币种金额。
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift currency must be COIN")
}
if cmd.MetadataJSON != "" && !json.Valid([]byte(cmd.MetadataJSON)) {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift metadata_json is invalid")
}
if cmd.PaidAtMS <= 0 {
// paid_at_ms 表示外部 App 扣费完成时间;缺失时用 UTC 服务端时间兜底,不能使用本地时区。
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
return cmd, nil
}
func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawCommand, error) {
// Draw 只接受扣费成功后的服务端事实;客户端不能提交价格、倍率或奖励字段。
cmd.CommandID = strings.TrimSpace(cmd.CommandID)
cmd.PoolID = normalizePoolID(cmd.PoolID)
cmd.GiftID = strings.TrimSpace(cmd.GiftID)
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
cmd.AnchorID = strings.TrimSpace(cmd.AnchorID)
if cmd.CommandID == "" || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
}
if cmd.TargetUserID < 0 || cmd.GiftCount <= 0 {
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
}
if cmd.PaidAtMS <= 0 {
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免本地时区污染 RTP 窗口。
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
return cmd, nil
}
type WorkerOptions struct {
WorkerID string
PollInterval time.Duration
BatchSize int
Concurrency int
LockTTL time.Duration
MaxRetry int
PublishTimeout time.Duration
StatsInterval time.Duration
StatsBatchSize int
}
func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
options = normalizeWorkerOptions(options)
ticker := time.NewTicker(options.PollInterval)
defer ticker.Stop()
nextStatsRefresh := time.Time{}
for {
if _, err := s.ProcessPendingDrawOutbox(ctx, options); err != nil && ctx.Err() == nil {
// 批处理失败不删除 outbox锁过期后下一轮或其他实例会继续补偿。
logx.Error(ctx, "lucky_gift_outbox_batch_failed", err, slog.String("worker_id", options.WorkerID))
}
now := s.now().UTC()
if options.StatsInterval > 0 && (nextStatsRefresh.IsZero() || !now.Before(nextStatsRefresh)) {
if _, err := s.repository.RefreshLuckyGiftAdminStatsSnapshots(ctx, now.UnixMilli(), options.StatsBatchSize); err != nil && ctx.Err() == nil {
logx.Error(ctx, "lucky_gift_admin_stats_refresh_failed", err, slog.String("worker_id", options.WorkerID))
}
if _, err := s.repository.RefreshLuckyGiftDatabiStatsSnapshots(ctx, now.UnixMilli(), options.StatsBatchSize); err != nil && ctx.Err() == nil {
logx.Error(ctx, "lucky_gift_databi_stats_refresh_failed", err, slog.String("worker_id", options.WorkerID))
}
nextStatsRefresh = now.Add(options.StatsInterval)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (s *Service) ProcessPendingDrawOutbox(ctx context.Context, options WorkerOptions) (int, error) {
if err := s.requireRepository(); err != nil {
return 0, err
}
options = normalizeWorkerOptions(options)
// outbox 只承载钱包返奖结算,避免多实例同时给同一 draw 返奖。
events, err := s.repository.ClaimPendingLuckyGiftOutbox(ctx, options.WorkerID, s.now().UTC().UnixMilli(), options.LockTTL, options.BatchSize)
if err != nil {
return 0, err
}
if len(events) == 0 {
return 0, nil
}
workerCount := options.Concurrency
if workerCount > len(events) {
workerCount = len(events)
}
jobs := make(chan domain.DrawOutbox)
errs := make(chan error, len(events))
var wg sync.WaitGroup
for index := 0; index < workerCount; index++ {
wg.Add(1)
go func() {
defer wg.Done()
for event := range jobs {
if err := s.processDrawOutbox(ctx, event, options); err != nil {
errs <- err
continue
}
errs <- nil
}
}()
}
for _, event := range events {
select {
case <-ctx.Done():
close(jobs)
wg.Wait()
close(errs)
return 0, ctx.Err()
case jobs <- event:
}
}
close(jobs)
wg.Wait()
close(errs)
processed := 0
var firstErr error
for err := range errs {
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
processed++
}
return processed, firstErr
}
func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox, options WorkerOptions) error {
payload, err := decodeDrawOutboxPayload(event.PayloadJSON)
if err != nil {
// payload 不可解析说明抽奖事实旁路已经损坏,重试也无法恢复,直接走失败收敛。
return s.markOutboxFailed(ctx, event, options, err, false)
}
eventCtx := appcode.WithContext(ctx, event.AppCode)
switch event.EventType {
case domain.EventTypeLuckyGiftRewardSettlement, domain.EventTypeLuckyGiftDrawn, "":
return s.processRewardSettlementOutbox(eventCtx, event, payload, options)
case domain.EventTypeExternalLuckyGiftDrawn:
// 外部 App 自己负责余额扣减和返奖入账HyApp 只保存抽奖审计事实;该 outbox 的职责是给后续 RocketMQ/审计扩展留事实出口。
return s.repository.MarkLuckyGiftOutboxDelivered(eventCtx, event, s.now().UTC().UnixMilli())
default:
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("unsupported lucky gift outbox event type: %s", event.EventType), false)
}
}
func (s *Service) processRewardSettlementOutbox(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) error {
credited, alreadyGranted, receipt, err := s.settleLuckyGiftReward(ctx, event, payload, options)
if err != nil {
return err
}
if credited && !alreadyGranted {
// 钱包入账和 activity draw 状态都已经落库;这里的 C2C 只是降低客户端余额刷新延迟,失败时仍由 wallet outbox -> notice-service 补偿。
if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, options.PublishTimeout); err != nil {
logx.Error(ctx, "lucky_gift_wallet_balance_notice_failed", err,
slog.String("draw_id", payload.DrawID),
slog.String("command_id", payload.CommandID),
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
)
}
}
// 幸运礼物不再产生跨用户展示副作用outbox 只负责可靠返奖和中奖用户余额通知,避免展示行为改变结算状态。
return s.repository.MarkLuckyGiftOutboxDelivered(ctx, event, s.now().UTC().UnixMilli())
}
func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) (bool, bool, luckyGiftRewardReceipt, error) {
walletTransactionID := ""
credited := false
alreadyGranted := false
receipt := luckyGiftRewardReceipt{}
if payload.EffectiveRewardCoins > 0 {
state, err := s.repository.GetLuckyGiftDrawRewardState(ctx, event.AppCode, payload.DrawIDs)
if err != nil {
return false, false, receipt, s.markOutboxFailed(ctx, event, options, err, false)
}
alreadyGranted = state.AllGranted
if alreadyGranted {
walletTransactionID = state.WalletTransactionID
credited = true
} else {
var err error
receipt, err = s.creditLuckyGiftReward(ctx, event.AppCode, payload)
if err != nil {
return false, false, receipt, s.markOutboxFailed(ctx, event, options, err, false)
}
walletTransactionID = receipt.WalletTransactionID
credited = true
}
}
if !credited {
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil {
return false, false, receipt, err
}
return false, false, receipt, nil
}
if !alreadyGranted {
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil {
return credited, alreadyGranted, receipt, err
}
}
return credited, alreadyGranted, receipt, nil
}
type luckyGiftRewardReceipt struct {
WalletTransactionID string
CoinBalanceAfter int64
CoinFrozenAfter int64
BalanceVersion int64
}
func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
if result.EffectiveRewardCoins <= 0 || result.RewardStatus == domain.StatusGranted {
return result
}
payload := luckyGiftDrawnPayload{
AppCode: appcode.FromContext(ctx),
DrawID: result.DrawID,
DrawIDs: luckyDrawResultIDs(result),
CommandID: cmd.CommandID,
PoolID: result.PoolID,
RoomID: cmd.RoomID,
GiftID: cmd.GiftID,
GiftCount: cmd.GiftCount,
UserID: cmd.UserID,
SenderUserID: cmd.UserID,
TargetUserID: cmd.TargetUserID,
VisibleRegionID: cmd.VisibleRegionID,
CountryID: cmd.CountryID,
CoinSpent: cmd.CoinSpent,
RuleVersion: result.RuleVersion,
ExperiencePool: result.ExperiencePool,
SelectedTierID: result.SelectedTierID,
MultiplierPPM: result.MultiplierPPM,
BaseRewardCoins: result.BaseRewardCoins,
EffectiveRewardCoins: result.EffectiveRewardCoins,
StageFeedback: result.StageFeedback,
HighMultiplier: result.HighMultiplier,
CreatedAtMS: result.CreatedAtMS,
}
receipt, err := s.creditLuckyGiftReward(ctx, payload.AppCode, payload)
if err != nil {
logx.Error(ctx, "lucky_gift_reward_fast_path_failed", err,
slog.String("draw_id", result.DrawID),
slog.String("command_id", result.CommandID),
slog.Int64("reward_coins", result.EffectiveRewardCoins),
)
return result
}
result.RewardStatus = domain.StatusGranted
result.WalletTransactionID = receipt.WalletTransactionID
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, payload.AppCode, payload.DrawIDs, receipt.WalletTransactionID); err != nil {
logx.Error(ctx, "lucky_gift_reward_fast_path_mark_granted_failed", err,
slog.String("draw_id", result.DrawID),
slog.String("command_id", result.CommandID),
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
)
return result
}
// HTTP/gRPC 抽奖响应不再返回返奖后余额;成功落库后的余额变化通过钱包私有 IM 发送Flutter 用 balance_after 覆盖本地余额。
if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, 3*time.Second); err != nil {
logx.Error(ctx, "lucky_gift_wallet_balance_notice_failed", err,
slog.String("draw_id", result.DrawID),
slog.String("command_id", result.CommandID),
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
)
}
return result
}
func (s *Service) markLuckyGiftDrawsGrantedAfterWallet(ctx context.Context, appCode string, drawIDs []string, walletTransactionID string) error {
baseCtx := context.WithoutCancel(ctx)
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
markCtx, cancel := context.WithTimeout(appcode.WithContext(baseCtx, appCode), 5*time.Second)
err := s.repository.MarkLuckyGiftDrawsGranted(markCtx, appCode, drawIDs, walletTransactionID, s.now().UTC().UnixMilli())
cancel()
if err == nil {
return nil
}
lastErr = err
if !isRetryableLuckyGrantMarkError(err) {
return err
}
time.Sleep(time.Duration(attempt+1) * 50 * time.Millisecond)
}
return lastErr
}
func (s *Service) creditLuckyGiftReward(ctx context.Context, appCode string, payload luckyGiftDrawnPayload) (luckyGiftRewardReceipt, error) {
if s.wallet == nil {
return luckyGiftRewardReceipt{}, fmt.Errorf("lucky gift wallet client is not configured")
}
resp, err := s.wallet.CreditLuckyGiftReward(appcode.WithContext(ctx, appCode), &walletv1.CreditLuckyGiftRewardRequest{
CommandId: "lucky_reward:" + payload.DrawID,
AppCode: appCode,
TargetUserId: payload.UserID,
Amount: payload.EffectiveRewardCoins,
DrawId: payload.DrawID,
RoomId: payload.RoomID,
GiftId: payload.GiftID,
PoolId: payload.PoolID,
Reason: "lucky_gift_reward",
VisibleRegionId: payload.VisibleRegionID,
CountryId: payload.CountryID,
})
if err != nil {
return luckyGiftRewardReceipt{}, err
}
return luckyGiftRewardReceipt{
WalletTransactionID: resp.GetTransactionId(),
CoinBalanceAfter: resp.GetBalance().GetAvailableAmount(),
CoinFrozenAfter: resp.GetBalance().GetFrozenAmount(),
BalanceVersion: resp.GetBalance().GetVersion(),
}, nil
}
func (s *Service) publishLuckyGiftWalletBalanceChanged(ctx context.Context, payload luckyGiftDrawnPayload, receipt luckyGiftRewardReceipt, timeout time.Duration) error {
if s.userPub == nil || payload.EffectiveRewardCoins <= 0 {
return nil
}
receipt.WalletTransactionID = strings.TrimSpace(receipt.WalletTransactionID)
if receipt.WalletTransactionID == "" {
return fmt.Errorf("lucky gift wallet transaction id is empty")
}
nowMs := s.now().UTC().UnixMilli()
commandID := "lucky_reward:" + payload.DrawID
noticePayload, err := json.Marshal(map[string]any{
"event_type": "WalletBalanceChanged",
"event_id": walletBalanceChangedEventID(receipt.WalletTransactionID, payload.UserID, "COIN"),
"app_code": payload.AppCode,
"transaction_id": receipt.WalletTransactionID,
"command_id": commandID,
"user_id": fmt.Sprintf("%d", payload.UserID),
"asset_type": "COIN",
"available_delta": payload.EffectiveRewardCoins,
"frozen_delta": 0,
"available_after": receipt.CoinBalanceAfter,
"frozen_after": receipt.CoinFrozenAfter,
"balance_version": receipt.BalanceVersion,
"created_at_ms": nowMs,
"source_created_at_ms": nowMs,
"metadata": map[string]any{
"transaction_id": receipt.WalletTransactionID,
"command_id": commandID,
"user_id": payload.UserID,
"draw_id": payload.DrawID,
"room_id": payload.RoomID,
"visible_region_id": payload.VisibleRegionID,
"region_id": payload.VisibleRegionID,
"country_id": payload.CountryID,
"gift_id": payload.GiftID,
"pool_id": payload.PoolID,
"amount": payload.EffectiveRewardCoins,
"reason": "lucky_gift_reward",
"created_at_ms": nowMs,
},
})
if err != nil {
return err
}
publishTimeout := timeout
if publishTimeout <= 0 {
publishTimeout = 5 * time.Second
}
publishCtx, cancel := context.WithTimeout(ctx, publishTimeout)
defer cancel()
return s.userPub.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
ToAccount: tencentim.FormatUserID(payload.UserID),
EventID: walletBalanceChangedEventID(receipt.WalletTransactionID, payload.UserID, "COIN"),
Desc: "WalletBalanceChanged",
Ext: "wallet_notice",
PayloadJSON: noticePayload,
})
}
func walletBalanceChangedEventID(transactionID string, userID int64, assetType string) string {
hashInput := fmt.Sprintf("%s|%s|%d|%s", strings.TrimSpace(transactionID), "WalletBalanceChanged", userID, strings.TrimSpace(assetType))
sum := sha256.Sum256([]byte(hashInput))
return "wev_" + hex.EncodeToString(sum[:])
}
func (s *Service) markOutboxFailed(ctx context.Context, event domain.DrawOutbox, options WorkerOptions, cause error, alreadyCredited bool) error {
nowMS := s.now().UTC().UnixMilli()
nextRetryCount := event.RetryCount + 1
if nextRetryCount >= options.MaxRetry {
if !alreadyCredited {
// 未返奖的 draw 可以整批标记 failed钱包已返奖时不能覆盖账务事实。
_ = s.repository.MarkLuckyGiftDrawsFailed(ctx, event.AppCode, drawIDsFromPayload(event.PayloadJSON), cause.Error(), nowMS)
}
return s.repository.MarkLuckyGiftOutboxFailed(ctx, event, nextRetryCount, cause.Error(), nowMS)
}
// 指数退避降低故障期间对钱包的压力,同时保留 pending 状态给客户端展示“处理中”。
nextRetryAtMS := nowMS + luckyGiftBackoff(nextRetryCount).Milliseconds()
return s.repository.MarkLuckyGiftOutboxRetryable(ctx, event, nextRetryCount, nextRetryAtMS, cause.Error(), nowMS)
}
func (s *Service) GetConfig(ctx context.Context, poolID string) (domain.RuleConfig, error) {
if err := s.requireRepository(); err != nil {
return domain.RuleConfig{}, err
}
poolID = normalizePoolID(poolID)
config, exists, err := s.repository.GetLuckyGiftRuleConfig(ctx, poolID)
if err != nil {
return domain.RuleConfig{}, err
}
if !exists {
// 未配置时返回 disabled v2 草稿,后台可以直接编辑发布;运行抽奖不会读取这个草稿。
return DefaultRuleConfig(appcode.FromContext(ctx), poolID), nil
}
return config, nil
}
func (s *Service) UpsertConfig(ctx context.Context, config domain.RuleConfig) (domain.RuleConfig, error) {
if err := s.requireRepository(); err != nil {
return domain.RuleConfig{}, err
}
config.AppCode = appcode.FromContext(ctx)
config.PoolID = normalizePoolID(config.PoolID)
config = normalizeRuleConfig(config)
if err := validateRuleConfig(config); err != nil {
return domain.RuleConfig{}, err
}
return s.repository.PublishLuckyGiftRuleConfig(ctx, config, s.now().UTC().UnixMilli())
}
func (s *Service) ListConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error) {
if err := s.requireRepository(); err != nil {
return nil, err
}
return s.repository.ListLuckyGiftRuleConfigs(ctx, strings.TrimSpace(appCode))
}
func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) {
if err := s.requireRepository(); err != nil {
return nil, 0, err
}
query = normalizeDrawQuery(query)
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return s.repository.ListLuckyGiftDraws(ctx, query)
}
func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
if err := s.requireRepository(); err != nil {
return domain.DrawSummary{}, err
}
return s.repository.GetLuckyGiftDrawSummary(ctx, normalizeDrawQuery(query))
}
func (s *Service) ListPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error) {
if err := s.requireRepository(); err != nil {
return nil, err
}
query.AppCode = strings.ToLower(strings.TrimSpace(query.AppCode))
if strings.TrimSpace(query.PoolID) != "" {
query.PoolID = normalizePoolID(query.PoolID)
}
return s.repository.ListLuckyGiftPoolBalances(ctx, query)
}
func (s *Service) requireRepository() error {
if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "lucky gift repository is not configured")
}
return nil
}
func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery {
query.AppCode = strings.TrimSpace(query.AppCode)
query.PoolID = normalizePoolID(query.PoolID)
query.GiftID = strings.TrimSpace(query.GiftID)
query.RoomID = strings.TrimSpace(query.RoomID)
query.Status = strings.TrimSpace(query.Status)
query.ExternalUserID = strings.TrimSpace(query.ExternalUserID)
return query
}
type luckyGiftDrawnPayload struct {
EventType string `json:"event_type"`
EventID string `json:"event_id"`
AppCode string `json:"app_code"`
DrawID string `json:"draw_id"`
// DrawIDs 是聚合发放的明细集合;老 outbox 没有该字段时 decode 会回填为单个 DrawID。
DrawIDs []string `json:"draw_ids,omitempty"`
CommandID string `json:"command_id"`
PoolID string `json:"pool_id"`
RoomID string `json:"room_id"`
GiftID string `json:"gift_id"`
GiftCount int32 `json:"gift_count"`
UserID int64 `json:"user_id"`
SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"`
SenderName string `json:"sender_name,omitempty"`
SenderAvatar string `json:"sender_avatar,omitempty"`
SenderDisplayUserID string `json:"sender_display_user_id,omitempty"`
VisibleRegionID int64 `json:"visible_region_id"`
CountryID int64 `json:"country_id"`
CoinSpent int64 `json:"coin_spent"`
RuleVersion int64 `json:"rule_version"`
ExperiencePool string `json:"experience_pool"`
SelectedTierID string `json:"selected_tier_id"`
MultiplierPPM int64 `json:"multiplier_ppm"`
BaseRewardCoins int64 `json:"base_reward_coins"`
EffectiveRewardCoins int64 `json:"effective_reward_coins"`
StageFeedback bool `json:"stage_feedback"`
HighMultiplier bool `json:"high_multiplier"`
CreatedAtMS int64 `json:"created_at_ms"`
}
func decodeDrawOutboxPayload(payloadJSON string) (luckyGiftDrawnPayload, error) {
var payload luckyGiftDrawnPayload
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
return luckyGiftDrawnPayload{}, fmt.Errorf("invalid lucky gift outbox payload: %w", err)
}
// 兼容早期 payload 缺省字段,避免历史 pending outbox 因新增字段永久失败。
if payload.EventType == "" {
payload.EventType = "lucky_gift_drawn"
}
// 兼容两类 outbox老单抽 payload 只有 draw_id新批量 payload 同时有聚合 draw_id 和 draw_ids。
if payload.DrawID == "" && len(payload.DrawIDs) > 0 {
payload.DrawID = payload.DrawIDs[0]
}
if len(payload.DrawIDs) == 0 && payload.DrawID != "" {
payload.DrawIDs = []string{payload.DrawID}
}
if payload.EventID == "" && payload.DrawID != "" {
payload.EventID = "lucky_gift_drawn:" + payload.DrawID
}
if payload.SenderUserID == 0 {
payload.SenderUserID = payload.UserID
}
// 这些字段是返奖、房间 IM 和幂等去重的最小闭环,缺任何一个都不能安全补偿。
if payload.DrawID == "" || payload.EventID == "" || payload.RoomID == "" || payload.GiftID == "" || payload.UserID <= 0 {
return luckyGiftDrawnPayload{}, fmt.Errorf("lucky gift outbox payload is incomplete")
}
return payload, nil
}
// drawIDsFromPayload 只用于失败收敛;解析失败时返回 nil让仓储层保持 no-op避免误更新未知 draw。
func drawIDsFromPayload(payloadJSON string) []string {
payload, err := decodeDrawOutboxPayload(payloadJSON)
if err != nil {
return nil
}
return payload.DrawIDs
}
func luckyDrawResultIDs(result domain.DrawResult) []string {
if len(result.DrawIDs) > 0 {
return append([]string(nil), result.DrawIDs...)
}
if strings.TrimSpace(result.DrawID) == "" {
return nil
}
return []string{result.DrawID}
}
func normalizeWorkerOptions(options WorkerOptions) WorkerOptions {
options.WorkerID = strings.TrimSpace(options.WorkerID)
if options.WorkerID == "" {
options.WorkerID = "lucky-gift-worker"
}
if options.PollInterval <= 0 {
options.PollInterval = time.Second
}
if options.BatchSize <= 0 {
options.BatchSize = 100
}
if options.BatchSize > 500 {
options.BatchSize = 500
}
if options.Concurrency <= 0 {
options.Concurrency = 4
}
if options.Concurrency > options.BatchSize {
options.Concurrency = options.BatchSize
}
if options.LockTTL <= 0 {
options.LockTTL = 30 * time.Second
}
if options.MaxRetry <= 0 {
options.MaxRetry = 8
}
if options.PublishTimeout <= 0 {
options.PublishTimeout = 5 * time.Second
}
if options.StatsInterval <= 0 {
options.StatsInterval = 10 * time.Minute
}
if options.StatsBatchSize <= 0 {
options.StatsBatchSize = 5000
}
if options.StatsBatchSize > 50000 {
options.StatsBatchSize = 50000
}
return options
}
func luckyGiftBackoff(retryCount int) time.Duration {
if retryCount < 1 {
retryCount = 1
}
if retryCount > 6 {
retryCount = 6
}
return time.Duration(1<<retryCount) * time.Second
}
func isRetryableLuckyGrantMarkError(err error) bool {
if err == nil {
return false
}
message := err.Error()
return strings.Contains(message, "Deadlock") ||
strings.Contains(message, "1213") ||
strings.Contains(message, "40001") ||
strings.Contains(message, "Lock wait timeout")
}