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