package mysql import ( "context" "crypto/sha256" "database/sql" "encoding/binary" "encoding/hex" "encoding/json" "errors" "strings" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/xerr" lotteryengine "hyapp/services/lucky-gift-service/internal/domain/lotteryengine" domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" ) type externalDrawRow struct { DrawID string RequestID string AppCode string ExternalUserID string GiftCount int64 UnitAmount int64 TotalAmount int64 RewardAmount int64 MultiplierPPM int64 RewardStatus string RuleVersion int64 CreatedAtMS int64 } func externalLuckyUserID(appCode string, externalUserID string) int64 { hash := sha256.Sum256([]byte(strings.Join([]string{appcode.Normalize(appCode), externalUserID}, "\x00"))) const maxPositiveInt64 = ^uint64(0) >> 1 // 外部 App 用户没有 HyApp user_id;稳定 hash 到正整数 ID,使用户阶段状态和抽奖审计可以复用统一字段。 value := int64(binary.BigEndian.Uint64(hash[:8]) & maxPositiveInt64) if value == 0 { return 1 } return value } func externalLuckyScopeID(appCode string) string { // 外部 App 接入没有房间维度;使用 app_code 的稳定 hash 作为虚拟房间作用域,避免污染真实房间奖池。 return externalLuckyHashScope("external", appcode.Normalize(appCode)) } func externalLuckyDeviceID(appCode string, externalUserID string) string { // 外部用户 ID 最长可到 128 字符;hash 后写入审计字段,避免长字符串碰到内部设备字段长度边界。 return externalLuckyHashScope("external_device", appcode.Normalize(appCode), externalUserID) } func externalLuckyAnchorID(appCode string) string { // 外部 App 没有主播维度;使用 app 级稳定 scope,避免和内部主播 ID 混用。 return externalLuckyHashScope("external_anchor", appcode.Normalize(appCode)) } func externalLuckyHashScope(prefix string, parts ...string) string { hash := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) return prefix + ":" + hex.EncodeToString(hash[:16]) } // ExecuteExternalGiftDraw 在一个事务里完成外部 App 请求幂等、规则快照读取、随机抽奖、审计记录和 outbox。 // 调用方余额已经在自己的系统完成扣费,本事务不触碰 HyApp wallet;但 RTP、奖池和外部用户体验状态仍在本服务内推进。 func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.ExternalDrawCommand, nowMS int64) (domain.ExternalDrawResult, error) { if r == nil || r.db == nil { return domain.ExternalDrawResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return domain.ExternalDrawResult{}, err } defer func() { _ = tx.Rollback() }() if existing, ok, err := r.getExternalGiftDrawForUpdate(ctx, tx, cmd.AppCode, cmd.RequestID); err != nil || ok { if err != nil { return domain.ExternalDrawResult{}, err } // 幂等命中直接返回原始结果,不能重新随机,也不能因为重试再次写 outbox。 return externalDrawResultFromRow(existing), tx.Commit() } poolID := luckyPoolID(cmd.PoolID) rule, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, cmd.AppCode, poolID, false) if err != nil { return domain.ExternalDrawResult{}, err } selected := externalDrawCandidate{TierID: "disabled", MultiplierPPM: 0} rewardAmount := int64(0) ruleVersion := rule.RuleVersion if exists && rule.Enabled { selected, rewardAmount, err = r.executeExternalGiftEconomy(ctx, tx, cmd, rule, nowMS) if err != nil { return domain.ExternalDrawResult{}, err } } drawID := idgen.New("external_lucky_draw") row := externalDrawRow{ DrawID: drawID, RequestID: cmd.RequestID, AppCode: cmd.AppCode, ExternalUserID: cmd.ExternalUserID, GiftCount: cmd.GiftCount, UnitAmount: cmd.UnitAmount, TotalAmount: cmd.TotalAmount, RewardAmount: rewardAmount, MultiplierPPM: selected.MultiplierPPM, RewardStatus: domain.StatusGranted, RuleVersion: ruleVersion, CreatedAtMS: nowMS, } metadata := strings.TrimSpace(cmd.MetadataJSON) if metadata == "" { metadata = "{}" } if _, err := tx.ExecContext(ctx, ` INSERT INTO external_lucky_gift_draws ( app_code, request_id, draw_id, external_user_id, pool_id, gift_count, unit_amount, total_amount, currency, metadata_json, rule_version, selected_tier_id, multiplier_ppm, reward_amount, reward_status, paid_at_ms, created_at_ms, updated_at_ms ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, cmd.AppCode, cmd.RequestID, drawID, cmd.ExternalUserID, poolID, cmd.GiftCount, cmd.UnitAmount, cmd.TotalAmount, cmd.Currency, metadata, ruleVersion, selected.TierID, selected.MultiplierPPM, rewardAmount, row.RewardStatus, cmd.PaidAtMS, nowMS, nowMS, ); err != nil { return domain.ExternalDrawResult{}, err } payload, err := json.Marshal(map[string]any{ "event_type": domain.EventTypeExternalLuckyGiftDrawn, "event_id": domain.EventTypeExternalLuckyGiftDrawn + ":" + drawID, "draw_id": drawID, "app_code": cmd.AppCode, "external_user_id": cmd.ExternalUserID, "request_id": cmd.RequestID, "pool_id": poolID, "gift_count": cmd.GiftCount, "unit_amount": cmd.UnitAmount, "total_amount": cmd.TotalAmount, "reward_amount": rewardAmount, "multiplier_ppm": selected.MultiplierPPM, "reward_status": row.RewardStatus, "rule_version": ruleVersion, "created_at_ms": nowMS, }) if err != nil { return domain.ExternalDrawResult{}, err } if err := r.insertLuckyDrawOutbox(ctx, tx, cmd.AppCode, "external_lucky_gift_drawn:"+drawID, domain.EventTypeExternalLuckyGiftDrawn, payload, nowMS); err != nil { return domain.ExternalDrawResult{}, err } if err := tx.Commit(); err != nil { return domain.ExternalDrawResult{}, err } return externalDrawResultFromRow(row), nil } func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx, cmd domain.ExternalDrawCommand, rule domain.RuleConfig, nowMS int64) (externalDrawCandidate, int64, error) { baseConfig, err := luckyRuntimeConfigFromRuleConfig(rule) if err != nil { return externalDrawCandidate{}, 0, err } referencePrice := baseConfig.GiftPrice config := luckyRuntimeConfig(baseConfig, cmd.TotalAmount) externalUserID := externalLuckyUserID(cmd.AppCode, cmd.ExternalUserID) roomScopeID := externalLuckyScopeID(cmd.AppCode) drawCommand := domain.DrawCommand{ CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID, PoolID: cmd.PoolID, UserID: externalUserID, DeviceID: externalLuckyDeviceID(cmd.AppCode, cmd.ExternalUserID), RoomID: roomScopeID, AnchorID: externalLuckyAnchorID(cmd.AppCode), GiftID: config.GiftID, GiftCount: int32(cmd.GiftCount), CoinSpent: cmd.TotalAmount, PaidAtMS: cmd.PaidAtMS, VisibleRegionID: 0, CountryID: 0, } userState, err := r.getLuckyUserStateForUpdate(ctx, tx, cmd.AppCode, externalUserID, config.GiftID, nowMS) if err != nil { return externalDrawCandidate{}, 0, err } nextCumulativeWager := userState.CumulativeWagerCoins + cmd.TotalAmount nextEquivalentDraws := luckyEquivalentDraws(nextCumulativeWager, referencePrice) experiencePool := luckyExperiencePool(nextEquivalentDraws, config) windowScopeID := luckyRuleWindowScopeID(config) globalWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, cmd.AppCode, "pool", windowScopeID, config.GlobalWindowDraws, config.TargetRTPPPM, nowMS) if err != nil { return externalDrawCandidate{}, 0, err } globalWindow = globalWindow.withCurrentWager(cmd.TotalAmount) giftWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, cmd.AppCode, "pool_gift", windowScopeID, config.GiftWindowDraws, config.TargetRTPPPM, nowMS) if err != nil { return externalDrawCandidate{}, 0, err } giftWindow = giftWindow.withCurrentWager(cmd.TotalAmount) // 外部 App 也使用 app+pool_id 单池;外部没有真实 HyApp 房间,因此不能再把 synthetic room 当资金隔离维度。 basePool, err := r.getOrCreateLuckyPool(ctx, tx, cmd.AppCode, luckyBasePoolScopeType, luckyBasePoolScopeID(config), config.InitialBasePool, config.BasePoolReserve, nowMS) if err != nil { return externalDrawCandidate{}, 0, err } if err := r.creditLuckyBasePool(ctx, tx, cmd.AppCode, config, cmd.TotalAmount, &basePool, nowMS); err != nil { return externalDrawCandidate{}, 0, err } candidate, _, err := r.selectLuckyCandidate(config, experiencePool, basePool) if err != nil { return externalDrawCandidate{}, 0, err } if err := r.applyExternalLuckyEconomy(ctx, tx, cmd.AppCode, config, drawCommand, candidate, globalWindow, giftWindow, basePool, nextCumulativeWager, nextEquivalentDraws, nowMS); err != nil { return externalDrawCandidate{}, 0, err } return externalDrawCandidate{TierID: candidate.TierID, MultiplierPPM: candidate.MultiplierPPM}, candidate.effectiveReward(), nil } func (r *Repository) applyExternalLuckyEconomy(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, candidate luckyCandidate, globalWindow luckyRTPWindow, giftWindow luckyRTPWindow, basePool luckyPool, nextCumulativeWager int64, nextEquivalentDraws int64, nowMS int64) error { if _, err := tx.ExecContext(ctx, ` UPDATE lucky_rtp_windows SET paid_draws = paid_draws + 1, wager_coins = ?, target_payout_coins = ?, actual_base_payout_coins = actual_base_payout_coins + ?, carry_ppm = ?, updated_at_ms = ? WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`, globalWindow.WagerCoins, globalWindow.TargetPayoutCoins, candidate.BaseReward, globalWindow.CarryPPM, nowMS, appCode, globalWindow.ScopeType, globalWindow.ScopeID, globalWindow.WindowIndex, ); err != nil { return err } if _, err := tx.ExecContext(ctx, ` UPDATE lucky_rtp_windows SET paid_draws = paid_draws + 1, wager_coins = ?, target_payout_coins = ?, actual_base_payout_coins = actual_base_payout_coins + ?, carry_ppm = ?, updated_at_ms = ? WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`, giftWindow.WagerCoins, giftWindow.TargetPayoutCoins, candidate.BaseReward, giftWindow.CarryPPM, nowMS, appCode, giftWindow.ScopeType, giftWindow.ScopeID, giftWindow.WindowIndex, ); err != nil { return err } // 外部抽奖只返回 reward_amount 给对方 App 结算,但 HyApp 内部仍要扣减单池,保证审计水位和内部路径一致。 if err := r.debitLuckyPool(ctx, tx, appCode, luckyBasePoolScopeType, basePool.ScopeID, candidate.BaseReward, nowMS); err != nil { return err } lossStreakSQL := "loss_streak + 1" if candidate.effectiveReward() > 0 { lossStreakSQL = "0" } _, err := tx.ExecContext(ctx, ` UPDATE lucky_user_states SET paid_draws = paid_draws + 1, cumulative_wager_coins = ?, equivalent_draws = ?, loss_streak = `+lossStreakSQL+`, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND gift_id = ?`, nextCumulativeWager, nextEquivalentDraws, nowMS, appCode, cmd.UserID, config.GiftID, ) return err } func (r *Repository) getExternalGiftDrawForUpdate(ctx context.Context, tx *sql.Tx, appCode string, requestID string) (externalDrawRow, bool, error) { row := tx.QueryRowContext(ctx, ` SELECT draw_id, request_id, app_code, external_user_id, gift_count, unit_amount, total_amount, reward_amount, multiplier_ppm, reward_status, rule_version, created_at_ms FROM external_lucky_gift_draws WHERE app_code = ? AND request_id = ? FOR UPDATE`, appCode, requestID) var item externalDrawRow if err := row.Scan( &item.DrawID, &item.RequestID, &item.AppCode, &item.ExternalUserID, &item.GiftCount, &item.UnitAmount, &item.TotalAmount, &item.RewardAmount, &item.MultiplierPPM, &item.RewardStatus, &item.RuleVersion, &item.CreatedAtMS, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return externalDrawRow{}, false, nil } return externalDrawRow{}, false, err } return item, true, nil } type externalDrawCandidate struct { TierID string MultiplierPPM int64 } func selectExternalDrawCandidate(rule domain.RuleConfig) (externalDrawCandidate, error) { candidates := make([]lotteryengine.Candidate, 0, 8) for _, stage := range rule.Stages { if stage.Stage != domain.StageNormal && len(candidates) > 0 { continue } for _, tier := range stage.Tiers { if !tier.Enabled || tier.BaseWeightPPM <= 0 { continue } candidates = append(candidates, lotteryengine.Candidate{ ID: tier.TierID, Weight: tier.BaseWeightPPM, PrizeKind: lotteryengine.PrizeKindCoin, RTPValueCoins: tier.MultiplierPPM, }) } if stage.Stage == domain.StageNormal && len(candidates) > 0 { break } } if len(candidates) == 0 { return externalDrawCandidate{TierID: "zero", MultiplierPPM: 0}, nil } selected, err := lotteryengine.SelectWeighted(candidates) if err != nil { return externalDrawCandidate{}, err } return externalDrawCandidate{TierID: selected.ID, MultiplierPPM: selected.RTPValueCoins}, nil } func externalDrawResultFromRow(row externalDrawRow) domain.ExternalDrawResult { return domain.ExternalDrawResult{ DrawID: row.DrawID, RequestID: row.RequestID, AppCode: row.AppCode, ExternalUserID: row.ExternalUserID, GiftCount: row.GiftCount, UnitAmount: row.UnitAmount, TotalAmount: row.TotalAmount, RewardAmount: row.RewardAmount, MultiplierPPM: row.MultiplierPPM, RewardStatus: row.RewardStatus, RuleVersion: row.RuleVersion, CreatedAtMS: row.CreatedAtMS, } } func (r *Repository) listExternalLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) { whereSQL, args := externalDrawWhereClause(query.AppCode, query) var total int64 if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM external_lucky_gift_draws WHERE `+whereSQL, args...).Scan(&total); err != nil { return nil, 0, err } limit := int(query.PageSize) offset := int((query.Page - 1) * query.PageSize) args = append(args, limit, offset) rows, err := r.db.QueryContext(ctx, ` SELECT draw_id, request_id, app_code, external_user_id, pool_id, rule_version, selected_tier_id, multiplier_ppm, reward_amount, total_amount, reward_status, created_at_ms FROM external_lucky_gift_draws WHERE `+whereSQL+` ORDER BY created_at_ms DESC, draw_id DESC LIMIT ? OFFSET ?`, args...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]domain.DrawResult, 0, limit) for rows.Next() { var item domain.DrawResult if err := rows.Scan( &item.DrawID, &item.CommandID, &item.AppCode, &item.ExternalUserID, &item.PoolID, &item.RuleVersion, &item.SelectedTierID, &item.MultiplierPPM, &item.EffectiveRewardCoins, &item.BaseRewardCoins, &item.RewardStatus, &item.CreatedAtMS, ); err != nil { return nil, 0, err } // 外部 App 抽奖没有内部 gift_id/room 维度;request_id 放到 command_id,外部用户字段单独返回给中台展示和排查。 item.DrawIDs = []string{item.DrawID} item.GiftID = "external" item.ExperiencePool = "external" items = append(items, item) } return items, total, rows.Err() } func (r *Repository) getExternalLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) { whereSQL, args := externalDrawWhereClause(query.AppCode, query) var summary domain.DrawSummary summary.PoolID = query.PoolID if err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*), COUNT(DISTINCT external_user_id), COALESCE(SUM(total_amount), 0), COALESCE(SUM(reward_amount), 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) FROM external_lucky_gift_draws WHERE `+whereSQL, args...).Scan( &summary.TotalDraws, &summary.UniqueUsers, &summary.TotalSpentCoins, &summary.TotalRewardCoins, &summary.GrantedDraws, &summary.FailedDraws, ); err != nil { return domain.DrawSummary{}, err } summary.BaseRewardCoins = summary.TotalRewardCoins if summary.TotalSpentCoins > 0 { summary.ActualRTPPPM = summary.TotalRewardCoins * luckyPPMScale / summary.TotalSpentCoins } return summary, nil } func externalDrawWhereClause(appCode string, query domain.DrawQuery) (string, []any) { where := []string{"1=1"} args := []any{} if strings.TrimSpace(appCode) != "" { where = append(where, "app_code = ?") args = append(args, appcode.Normalize(appCode)) } if strings.TrimSpace(query.PoolID) != "" { where = append(where, "pool_id = ?") args = append(args, query.PoolID) } if strings.TrimSpace(query.ExternalUserID) != "" { where = append(where, "external_user_id = ?") args = append(args, query.ExternalUserID) } if strings.TrimSpace(query.Status) != "" { where = append(where, "reward_status = ?") args = append(args, query.Status) } return strings.Join(where, " AND "), args }