2026-06-11 19:24:51 +08:00

686 lines
29 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"
"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 返回完整 participantsH5 可以直接刷新座位,不需要再额外查一次。
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
}