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, country_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.CountryID, 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, "country_id": match.CountryID, "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, country_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.CountryID, &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 }