435 lines
17 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 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
}