package mysql import ( "context" "database/sql" "encoding/json" "errors" "strings" "hyapp/pkg/appcode" "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 创建阶段固定写 [],后续 SaveDiceRolls 才允许写真实点数。 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, 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), participant.Result, participant.PayoutCoin, participant.DebitOrderID, participant.PayoutOrderID, participant.RefundOrderID, participant.BalanceAfter, participant.JoinedAtMS, participant.UpdatedAtMS, ); 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, 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, 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, "[]", 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 } 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 } return tx.Commit() } func (r *Repository) MarkDiceMatchFailed(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error { _, err := r.db.ExecContext(ctx, `UPDATE game_dice_matches SET status = ?, result = ?, updated_at_ms = ? WHERE app_code = ? AND match_id = ?`, dicedomain.MatchStatusFailed, strings.TrimSpace(result), nowMs, appcode.Normalize(appCode), strings.TrimSpace(matchID), ) return err } 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), '[]'), 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.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 }