686 lines
29 KiB
Go
686 lines
29 KiB
Go
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"strings"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/gamemq"
|
||
"hyapp/pkg/xerr"
|
||
dicedomain "hyapp/services/game-service/internal/domain/dice"
|
||
)
|
||
|
||
type diceQuerier interface {
|
||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||
}
|
||
|
||
// CreateDiceMatch 在同一个事务内写入 match 和首个参与者,保证创建后不会出现空局。
|
||
func (r *Repository) CreateDiceMatch(ctx context.Context, match dicedomain.Match) (dicedomain.Match, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
match.AppCode = appcode.Normalize(match.AppCode)
|
||
// match 和首个 participant 必须同事务提交;否则创建成功但参与者缺失会导致后续 roll 永远人数不足。
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO game_dice_matches (
|
||
app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id,
|
||
min_players, max_players, current_players, stake_coin, round_no, status, result,
|
||
join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms,
|
||
fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
match.AppCode, match.MatchID, match.GameID, match.PlatformCode, match.ProviderGameID, match.RoomID, match.RegionID,
|
||
match.MinPlayers, match.MaxPlayers, match.CurrentPlayers, match.StakeCoin, match.RoundNo, match.Status, match.Result,
|
||
match.JoinDeadlineMS, match.ReadyAtMS, match.CreatedAtMS, match.UpdatedAtMS, match.SettledAtMS, match.CanceledAtMS,
|
||
match.FeeBPS, match.PoolBPS, match.MatchMode, match.ForcedResult, match.PoolDeltaCoin,
|
||
); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
for _, participant := range match.Participants {
|
||
// 创建阶段只保存用户的座位和预选手势;dice_points_json 仍等到 roll/settlement 阶段才写入不可变结果标记。
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO game_dice_participants (
|
||
app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, dice_points_json, rps_gesture, result,
|
||
payout_coin, debit_order_id, payout_order_id, refund_order_id, balance_after, joined_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
match.AppCode, match.MatchID, participant.UserID, normalizeParticipantType(participant.ParticipantType), participant.SeatNo, participant.Status, participant.StakeCoin,
|
||
dicePointsJSON(participant.DicePoints), strings.TrimSpace(participant.RPSGesture), participant.Result, participant.PayoutCoin, participant.DebitOrderID,
|
||
participant.PayoutOrderID, participant.RefundOrderID, participant.BalanceAfter, participant.JoinedAtMS, participant.UpdatedAtMS,
|
||
); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
}
|
||
if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchCreated, match, match.CreatedAtMS); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
// 提交后重新读取,返回值使用数据库里的排序、默认值和 JSON 解析结果,不信任入参原对象。
|
||
return r.GetDiceMatch(ctx, match.AppCode, match.MatchID)
|
||
}
|
||
|
||
// JoinDiceMatch 只在 match 行锁内分配座位和增加人数;外部钱包调用不会持有这把锁。
|
||
func (r *Repository) JoinDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, rpsGesture string, nowMs int64) (dicedomain.Match, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
app := appcode.Normalize(appCode)
|
||
// 锁 match 主行后再检查状态和人数,保证并发 join 时只有一个事务能看到并更新同一份 current_players。
|
||
match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
if !diceMatchAllowsJoin(match.Status) {
|
||
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not joinable")
|
||
}
|
||
if match.JoinDeadlineMS > 0 && nowMs > match.JoinDeadlineMS {
|
||
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match join window is closed")
|
||
}
|
||
|
||
var existing int
|
||
if err := tx.QueryRowContext(ctx,
|
||
`SELECT COUNT(*)
|
||
FROM game_dice_participants
|
||
WHERE app_code = ? AND match_id = ? AND user_id = ?`,
|
||
app, match.MatchID, userID,
|
||
).Scan(&existing); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
if existing > 0 {
|
||
// 重复点击 join 直接返回当前局快照,不制造第二个座位,也不把重复请求当错误打断客户端刷新。
|
||
if err := tx.Commit(); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
return r.GetDiceMatch(ctx, app, match.MatchID)
|
||
}
|
||
if match.CurrentPlayers >= match.MaxPlayers {
|
||
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is full")
|
||
}
|
||
|
||
var seatNo int32
|
||
// seat_no 在同一把 match 锁下用 max+1 分配;配合唯一索引防止未来修改锁粒度后出现重复座位。
|
||
if err := tx.QueryRowContext(ctx,
|
||
`SELECT COALESCE(MAX(seat_no), 0) + 1
|
||
FROM game_dice_participants
|
||
WHERE app_code = ? AND match_id = ?`,
|
||
app, match.MatchID,
|
||
).Scan(&seatNo); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO game_dice_participants (
|
||
app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, dice_points_json, rps_gesture, result,
|
||
payout_coin, debit_order_id, payout_order_id, refund_order_id, balance_after, joined_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, '', 0, '', '', '', 0, ?, ?)`,
|
||
app, match.MatchID, userID, dicedomain.ParticipantTypeUser, seatNo, dicedomain.ParticipantStatusJoined, match.StakeCoin, "[]", strings.TrimSpace(rpsGesture), nowMs, nowMs,
|
||
); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
nextPlayers := match.CurrentPlayers + 1
|
||
nextStatus := dicedomain.MatchStatusJoining
|
||
readyAtMS := match.ReadyAtMS
|
||
if nextPlayers >= match.MinPlayers {
|
||
nextStatus = dicedomain.MatchStatusReady
|
||
if readyAtMS == 0 {
|
||
readyAtMS = nowMs
|
||
}
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE game_dice_matches
|
||
SET current_players = current_players + 1, status = ?, ready_at_ms = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND match_id = ?`,
|
||
nextStatus, readyAtMS, nowMs, app, match.MatchID,
|
||
); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
match.CurrentPlayers = nextPlayers
|
||
match.Status = nextStatus
|
||
match.ReadyAtMS = readyAtMS
|
||
match.UpdatedAtMS = nowMs
|
||
participants, err := queryDiceParticipants(ctx, tx, app, match.MatchID, false)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
match.Participants = participants
|
||
if nextStatus == dicedomain.MatchStatusReady {
|
||
if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchReady, match, nowMs); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
// join 返回完整 participants,H5 可以直接刷新座位,不需要再额外查一次。
|
||
return r.GetDiceMatch(ctx, app, match.MatchID)
|
||
}
|
||
|
||
func (r *Repository) GetDiceMatch(ctx context.Context, appCode string, matchID string) (dicedomain.Match, error) {
|
||
app := appcode.Normalize(appCode)
|
||
// 查询接口不加行锁,只读取当前已提交事实;所有会改变状态的入口都有单独的锁定方法。
|
||
match, err := queryDiceMatch(ctx, r.db, app, strings.TrimSpace(matchID), false)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
participants, err := queryDiceParticipants(ctx, r.db, app, match.MatchID, false)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
match.Participants = participants
|
||
return match, nil
|
||
}
|
||
|
||
// ClaimDiceMatchForRoll 把可结算局切到 settling;重复 roll 只能拿到同一局事实继续幂等补偿。
|
||
func (r *Repository) ClaimDiceMatchForRoll(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
app := appcode.Normalize(appCode)
|
||
// 先锁 match,再锁 participants,所有结算入口保持固定锁顺序,降低死锁概率。
|
||
match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
participants, err := queryDiceParticipants(ctx, tx, app, match.MatchID, true)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
match.Participants = participants
|
||
if match.Status == dicedomain.MatchStatusSettled {
|
||
// 已结算局允许 roll 重试读取结果,但不再改变任何状态。
|
||
if err := tx.Commit(); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
return match, nil
|
||
}
|
||
if !diceMatchAllowsRoll(match.Status) {
|
||
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not rollable")
|
||
}
|
||
if len(participants) < int(match.MinPlayers) {
|
||
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match does not have enough participants")
|
||
}
|
||
if !diceMatchHasParticipant(participants, userID) {
|
||
// 非参与者不能替别人开奖;权限判断放在锁内,避免刚 join 的并发事务未提交时误判状态。
|
||
return dicedomain.Match{}, xerr.New(xerr.PermissionDenied, "dice participant is required")
|
||
}
|
||
if match.Result == dicedomain.ParticipantResultDraw {
|
||
// 上一轮和局只用于 H5 展示点数;再次 roll 时必须清空旧点数和旧结果,避免 SaveDiceRolls 误判为幂等重试。
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE game_dice_participants
|
||
SET dice_points_json = CAST('[]' AS JSON), result = '', payout_coin = 0, updated_at_ms = ?
|
||
WHERE app_code = ? AND match_id = ?`,
|
||
nowMs, app, match.MatchID,
|
||
); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
for index := range match.Participants {
|
||
match.Participants[index].DicePoints = nil
|
||
match.Participants[index].Result = ""
|
||
match.Participants[index].PayoutCoin = 0
|
||
match.Participants[index].UpdatedAtMS = nowMs
|
||
}
|
||
match.Result = ""
|
||
}
|
||
// 状态推进到 settling 后立即提交,外部钱包调用不持有行锁;失败补偿靠订单幂等和状态字段继续推进。
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE game_dice_matches
|
||
SET status = ?, result = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND match_id = ?`,
|
||
dicedomain.MatchStatusSettling, match.Result, nowMs, app, match.MatchID,
|
||
); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
match.Status = dicedomain.MatchStatusSettling
|
||
match.UpdatedAtMS = nowMs
|
||
if err := tx.Commit(); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
return match, nil
|
||
}
|
||
|
||
// SaveDiceDrawForReroll 保存本轮和局点数,但不推进派奖;match 保持 ready,让 H5 2 秒后继续调用 roll。
|
||
func (r *Repository) SaveDiceDrawForReroll(ctx context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
app := appcode.Normalize(match.AppCode)
|
||
locked, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(match.MatchID), true)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
if locked.Status == dicedomain.MatchStatusSettled {
|
||
participants, err := queryDiceParticipants(ctx, tx, app, locked.MatchID, true)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
locked.Participants = participants
|
||
if err := tx.Commit(); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
return locked, nil
|
||
}
|
||
if locked.Status != dicedomain.MatchStatusSettling {
|
||
return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not saving draw")
|
||
}
|
||
for _, participant := range match.Participants {
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE game_dice_participants
|
||
SET dice_points_json = CAST(? AS JSON), result = ?, payout_coin = 0, updated_at_ms = ?
|
||
WHERE app_code = ? AND match_id = ? AND user_id = ?`,
|
||
dicePointsJSON(participant.DicePoints), dicedomain.ParticipantResultDraw, nowMs,
|
||
app, match.MatchID, participant.UserID,
|
||
); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE game_dice_matches
|
||
SET status = ?, result = ?, round_no = round_no + 1, updated_at_ms = ?
|
||
WHERE app_code = ? AND match_id = ?`,
|
||
dicedomain.MatchStatusReady, dicedomain.ParticipantResultDraw, nowMs, app, match.MatchID,
|
||
); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
return r.GetDiceMatch(ctx, app, match.MatchID)
|
||
}
|
||
|
||
// SaveDiceRolls 保存首次随机结果;如果重试时已经有点数,直接返回旧结果,保证不会重复随机。
|
||
func (r *Repository) SaveDiceRolls(ctx context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
app := appcode.Normalize(match.AppCode)
|
||
// 保存点数前再次锁局和参与者,防止两个补偿 worker 同时为同一局写入不同随机结果。
|
||
locked, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(match.MatchID), true)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
existing, err := queryDiceParticipants(ctx, tx, app, locked.MatchID, true)
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
if dicedomain.HasRolls(existing) {
|
||
// 点数已经存在时只返回旧事实;这条分支是“重试不重投”的最后防线。
|
||
locked.Participants = existing
|
||
if err := tx.Commit(); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
return locked, nil
|
||
}
|
||
|
||
for _, participant := range match.Participants {
|
||
// 点数、结果和 payout_coin 同时落参与者行,补偿任务可以只靠 participants 重建派奖命令。
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE game_dice_participants
|
||
SET dice_points_json = CAST(? AS JSON), result = ?, payout_coin = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND match_id = ? AND user_id = ?`,
|
||
dicePointsJSON(participant.DicePoints), participant.Result, participant.PayoutCoin, nowMs,
|
||
app, match.MatchID, participant.UserID,
|
||
); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE game_dice_matches
|
||
SET status = ?, result = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND match_id = ?`,
|
||
dicedomain.MatchStatusPayoutApplying, match.Result, nowMs, app, match.MatchID,
|
||
); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
// 返回提交后的完整事实,确保 service 后续派奖使用数据库中已固定的点数和 payout。
|
||
return r.GetDiceMatch(ctx, app, match.MatchID)
|
||
}
|
||
|
||
func (r *Repository) MarkDiceParticipantDebitSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
|
||
return r.updateDiceParticipantOrder(ctx, appCode, matchID, userID, dicedomain.ParticipantStatusDebitSucceeded, "debit_order_id", orderID, balanceAfter, nowMs)
|
||
}
|
||
|
||
func (r *Repository) MarkDiceParticipantDebitFailed(ctx context.Context, appCode string, matchID string, userID int64, orderID string, nowMs int64) error {
|
||
return r.updateDiceParticipantOrder(ctx, appCode, matchID, userID, dicedomain.ParticipantStatusDebitFailed, "debit_order_id", orderID, 0, nowMs)
|
||
}
|
||
|
||
func (r *Repository) MarkDiceParticipantPayoutSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
|
||
return r.updateDiceParticipantOrder(ctx, appCode, matchID, userID, dicedomain.ParticipantStatusPayoutSucceeded, "payout_order_id", orderID, balanceAfter, nowMs)
|
||
}
|
||
|
||
func (r *Repository) MarkDiceParticipantRefundSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
|
||
return r.updateDiceParticipantOrder(ctx, appCode, matchID, userID, dicedomain.ParticipantStatusRefundSucceeded, "refund_order_id", orderID, balanceAfter, nowMs)
|
||
}
|
||
|
||
func (r *Repository) updateDiceParticipantOrder(ctx context.Context, appCode string, matchID string, userID int64, status string, orderColumn string, orderID string, balanceAfter int64, nowMs int64) error {
|
||
// orderColumn 是 SQL 标识符,不能作为参数绑定;必须用白名单避免外部输入拼进 SQL。
|
||
switch orderColumn {
|
||
case "debit_order_id", "payout_order_id", "refund_order_id":
|
||
default:
|
||
return xerr.New(xerr.InvalidArgument, "dice participant order column is invalid")
|
||
}
|
||
_, err := r.db.ExecContext(ctx,
|
||
`UPDATE game_dice_participants
|
||
SET status = ?, `+orderColumn+` = ?, balance_after = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND match_id = ? AND user_id = ?`,
|
||
status, strings.TrimSpace(orderID), balanceAfter, nowMs, appcode.Normalize(appCode), strings.TrimSpace(matchID), userID,
|
||
)
|
||
return err
|
||
}
|
||
|
||
func (r *Repository) MarkDiceMatchSettled(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
app := appcode.Normalize(appCode)
|
||
// match 和 participants 在同一事务内改为 settled,客户端不会看到局已结算但参与者还停在 payout 状态。
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE game_dice_matches
|
||
SET status = ?, result = ?, updated_at_ms = ?, settled_at_ms = ?
|
||
WHERE app_code = ? AND match_id = ?`,
|
||
dicedomain.MatchStatusSettled, strings.TrimSpace(result), nowMs, nowMs, app, strings.TrimSpace(matchID),
|
||
); err != nil {
|
||
return err
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE game_dice_participants
|
||
SET status = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND match_id = ?`,
|
||
dicedomain.ParticipantStatusSettled, nowMs, app, strings.TrimSpace(matchID),
|
||
); err != nil {
|
||
return err
|
||
}
|
||
match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
participants, err := queryDiceParticipants(ctx, tx, app, match.MatchID, true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
match.Participants = participants
|
||
if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchSettled, match, nowMs); err != nil {
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
func (r *Repository) MarkDiceMatchFailed(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
app := appcode.Normalize(appCode)
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE game_dice_matches
|
||
SET status = ?, result = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND match_id = ?`,
|
||
dicedomain.MatchStatusFailed, strings.TrimSpace(result), nowMs, app, strings.TrimSpace(matchID),
|
||
); err != nil {
|
||
return err
|
||
}
|
||
match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
participants, err := queryDiceParticipants(ctx, tx, app, match.MatchID, true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
match.Participants = participants
|
||
if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchFailed, match, nowMs); err != nil {
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
func insertSelfGameMatchOutbox(ctx context.Context, tx *sql.Tx, eventType string, match dicedomain.Match, nowMs int64) error {
|
||
match.AppCode = appcode.Normalize(match.AppCode)
|
||
match.GameID = strings.TrimSpace(match.GameID)
|
||
if match.AppCode == "" || match.MatchID == "" || match.GameID == "" || nowMs <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "self game match event is incomplete")
|
||
}
|
||
payload := selfGameMatchOutboxPayload(match, nowMs)
|
||
raw, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
userID := int64(0)
|
||
if len(match.Participants) > 0 {
|
||
userID = match.Participants[0].UserID
|
||
}
|
||
// 自研局级事件没有单笔订单语义,order_id/op_type/coin_amount 只保留空值;统计服务按 event_type 解析 payload。
|
||
_, err = tx.ExecContext(ctx, `
|
||
INSERT IGNORE INTO game_outbox (
|
||
app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
|
||
coin_amount, payload_json, status, created_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, '', ?, ?, ?, '', 0, CAST(? AS JSON), 'pending', ?, ?)`,
|
||
match.AppCode, eventType+":"+match.MatchID, eventType, userID, strings.TrimSpace(match.PlatformCode),
|
||
match.GameID, string(raw), nowMs, nowMs,
|
||
)
|
||
return err
|
||
}
|
||
|
||
func selfGameMatchOutboxPayload(match dicedomain.Match, nowMs int64) map[string]any {
|
||
participants := make([]map[string]any, 0, len(match.Participants))
|
||
userStakeCoin, robotStakeCoin, payoutCoin, refundCoin := int64(0), int64(0), int64(0), int64(0)
|
||
userParticipants, robotParticipants, winners, losers, draws := int64(0), int64(0), int64(0), int64(0), int64(0)
|
||
for _, participant := range match.Participants {
|
||
isRobot := participant.ParticipantType == dicedomain.ParticipantTypeRobot
|
||
if isRobot {
|
||
robotParticipants++
|
||
robotStakeCoin += participant.StakeCoin
|
||
} else {
|
||
userParticipants++
|
||
userStakeCoin += participant.StakeCoin
|
||
}
|
||
switch participant.Result {
|
||
case dicedomain.ParticipantResultWin:
|
||
winners++
|
||
case dicedomain.ParticipantResultLose:
|
||
losers++
|
||
case dicedomain.ParticipantResultDraw:
|
||
draws++
|
||
if !isRobot {
|
||
refundCoin += participant.PayoutCoin
|
||
}
|
||
}
|
||
if !isRobot && participant.Result != dicedomain.ParticipantResultDraw {
|
||
payoutCoin += participant.PayoutCoin
|
||
}
|
||
netWinCoin := participant.PayoutCoin
|
||
if !isRobot {
|
||
netWinCoin -= participant.StakeCoin
|
||
}
|
||
participants = append(participants, map[string]any{
|
||
"user_id": participant.UserID,
|
||
"participant_type": participant.ParticipantType,
|
||
"seat_no": participant.SeatNo,
|
||
"status": participant.Status,
|
||
"stake_coin": participant.StakeCoin,
|
||
"dice_points": participant.DicePoints,
|
||
"rps_gesture": strings.TrimSpace(participant.RPSGesture),
|
||
"result": strings.TrimSpace(participant.Result),
|
||
"payout_coin": participant.PayoutCoin,
|
||
"net_win_coin": netWinCoin,
|
||
"joined_at_ms": participant.JoinedAtMS,
|
||
"updated_at_ms": participant.UpdatedAtMS,
|
||
})
|
||
}
|
||
waitMS, durationMS := int64(0), int64(0)
|
||
if match.ReadyAtMS > 0 && match.CreatedAtMS > 0 {
|
||
waitMS = match.ReadyAtMS - match.CreatedAtMS
|
||
}
|
||
finishedAtMS := match.SettledAtMS
|
||
if finishedAtMS == 0 {
|
||
finishedAtMS = match.CanceledAtMS
|
||
}
|
||
if finishedAtMS > 0 && match.CreatedAtMS > 0 {
|
||
durationMS = finishedAtMS - match.CreatedAtMS
|
||
}
|
||
return map[string]any{
|
||
"match_id": match.MatchID,
|
||
"game_id": strings.TrimSpace(match.GameID),
|
||
"platform_code": strings.TrimSpace(match.PlatformCode),
|
||
"provider_game_id": strings.TrimSpace(match.ProviderGameID),
|
||
"room_id": strings.TrimSpace(match.RoomID),
|
||
"region_id": match.RegionID,
|
||
"stake_coin": match.StakeCoin,
|
||
"min_players": match.MinPlayers,
|
||
"max_players": match.MaxPlayers,
|
||
"current_players": match.CurrentPlayers,
|
||
"status": strings.TrimSpace(match.Status),
|
||
"result": strings.TrimSpace(match.Result),
|
||
"match_mode": strings.TrimSpace(match.MatchMode),
|
||
"forced_result": strings.TrimSpace(match.ForcedResult),
|
||
"fee_bps": match.FeeBPS,
|
||
"pool_bps": match.PoolBPS,
|
||
"pool_delta_coin": match.PoolDeltaCoin,
|
||
"user_participants": userParticipants,
|
||
"robot_participants": robotParticipants,
|
||
"winner_count": winners,
|
||
"loser_count": losers,
|
||
"draw_count": draws,
|
||
"user_stake_coin": userStakeCoin,
|
||
"robot_stake_coin": robotStakeCoin,
|
||
"payout_coin": payoutCoin,
|
||
"refund_coin": refundCoin,
|
||
"platform_profit_coin": userStakeCoin - payoutCoin - refundCoin,
|
||
"join_deadline_ms": match.JoinDeadlineMS,
|
||
"ready_at_ms": match.ReadyAtMS,
|
||
"created_at_ms": match.CreatedAtMS,
|
||
"updated_at_ms": match.UpdatedAtMS,
|
||
"settled_at_ms": match.SettledAtMS,
|
||
"canceled_at_ms": match.CanceledAtMS,
|
||
"wait_ms": waitMS,
|
||
"duration_ms": durationMS,
|
||
"participants": participants,
|
||
"event_recorded_at_ms": nowMs,
|
||
}
|
||
}
|
||
|
||
func queryDiceMatch(ctx context.Context, q diceQuerier, appCode string, matchID string, forUpdate bool) (dicedomain.Match, error) {
|
||
// forUpdate 只给状态推进路径使用;读路径不加锁,避免普通刷新阻塞 join/roll。
|
||
query := `SELECT app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id,
|
||
min_players, max_players, current_players, stake_coin, round_no, status, result,
|
||
join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms,
|
||
fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin
|
||
FROM game_dice_matches
|
||
WHERE app_code = ? AND match_id = ?`
|
||
if forUpdate {
|
||
query += ` FOR UPDATE`
|
||
}
|
||
row := q.QueryRowContext(ctx, query, appcode.Normalize(appCode), strings.TrimSpace(matchID))
|
||
var match dicedomain.Match
|
||
if err := row.Scan(&match.AppCode, &match.MatchID, &match.GameID, &match.PlatformCode, &match.ProviderGameID, &match.RoomID, &match.RegionID, &match.MinPlayers, &match.MaxPlayers, &match.CurrentPlayers, &match.StakeCoin, &match.RoundNo, &match.Status, &match.Result, &match.JoinDeadlineMS, &match.ReadyAtMS, &match.CreatedAtMS, &match.UpdatedAtMS, &match.SettledAtMS, &match.CanceledAtMS, &match.FeeBPS, &match.PoolBPS, &match.MatchMode, &match.ForcedResult, &match.PoolDeltaCoin); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found")
|
||
}
|
||
return dicedomain.Match{}, err
|
||
}
|
||
return match, nil
|
||
}
|
||
|
||
func queryDiceParticipants(ctx context.Context, q diceQuerier, appCode string, matchID string, forUpdate bool) ([]dicedomain.Participant, error) {
|
||
// participants 始终按座位排序,HTTP 响应和补偿重放都能得到稳定顺序。
|
||
query := `SELECT app_code, match_id, user_id, participant_type, seat_no, status, stake_coin,
|
||
COALESCE(CAST(dice_points_json AS CHAR), '[]'), rps_gesture, result, payout_coin,
|
||
debit_order_id, payout_order_id, refund_order_id, balance_after, joined_at_ms, updated_at_ms
|
||
FROM game_dice_participants
|
||
WHERE app_code = ? AND match_id = ?
|
||
ORDER BY seat_no ASC`
|
||
if forUpdate {
|
||
query += ` FOR UPDATE`
|
||
}
|
||
rows, err := q.QueryContext(ctx, query, appcode.Normalize(appCode), strings.TrimSpace(matchID))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
participants := []dicedomain.Participant{}
|
||
for rows.Next() {
|
||
var participant dicedomain.Participant
|
||
var pointsJSON string
|
||
if err := rows.Scan(&participant.AppCode, &participant.MatchID, &participant.UserID, &participant.ParticipantType, &participant.SeatNo, &participant.Status, &participant.StakeCoin, &pointsJSON, &participant.RPSGesture, &participant.Result, &participant.PayoutCoin, &participant.DebitOrderID, &participant.PayoutOrderID, &participant.RefundOrderID, &participant.BalanceAfter, &participant.JoinedAtMS, &participant.UpdatedAtMS); err != nil {
|
||
return nil, err
|
||
}
|
||
participant.ParticipantType = normalizeParticipantType(participant.ParticipantType)
|
||
participant.DicePoints = parseDicePoints(pointsJSON)
|
||
participants = append(participants, participant)
|
||
}
|
||
return participants, rows.Err()
|
||
}
|
||
|
||
func diceMatchAllowsJoin(status string) bool {
|
||
// 只有未进入结算的局允许加入;settling 之后人数和奖池都必须冻结。
|
||
switch status {
|
||
case dicedomain.MatchStatusCreated, dicedomain.MatchStatusJoining:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func diceMatchAllowsRoll(status string) bool {
|
||
// settling/payout_applying 允许再次进入,是为了补偿同一局未完成的钱包步骤,不代表重新开奖。
|
||
switch status {
|
||
case dicedomain.MatchStatusCreated, dicedomain.MatchStatusJoining, dicedomain.MatchStatusReady, dicedomain.MatchStatusSettling, dicedomain.MatchStatusPayoutApplying:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func diceMatchHasParticipant(participants []dicedomain.Participant, userID int64) bool {
|
||
for _, participant := range participants {
|
||
if participant.UserID == userID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func dicePointsJSON(points []int32) string {
|
||
if len(points) == 0 {
|
||
return "[]"
|
||
}
|
||
raw, err := json.Marshal(points)
|
||
if err != nil {
|
||
return "[]"
|
||
}
|
||
return string(raw)
|
||
}
|
||
|
||
func parseDicePoints(raw string) []int32 {
|
||
var points []int32
|
||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &points); err != nil {
|
||
// JSON 异常按空点数处理,让上层 Score/HasRolls 决定是否能继续结算,不在扫描阶段吞掉整行。
|
||
return nil
|
||
}
|
||
return points
|
||
}
|