diff --git a/scripts/mysql/045_remove_default_self_games.sql b/scripts/mysql/045_remove_default_self_games.sql new file mode 100644 index 00000000..e58af010 --- /dev/null +++ b/scripts/mysql/045_remove_default_self_games.sql @@ -0,0 +1,31 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_game; + +-- 清理旧初始化脚本写入的自研游戏展示入口;不删除对局、钱包、outbox 和奖池流水,避免破坏历史业务事实。 +DELETE FROM game_display_rules +WHERE app_code = 'lalu' + AND game_id IN ('dice', 'rock'); + +DELETE FROM game_self_game_configs +WHERE app_code = 'lalu' + AND game_id IN ('dice', 'rock'); + +DELETE FROM game_self_game_pools +WHERE app_code = 'lalu' + AND game_id IN ('dice', 'rock'); + +DELETE FROM game_catalog +WHERE app_code = 'lalu' + AND game_id IN ('dice', 'rock'); + +DELETE p +FROM game_platforms p +WHERE p.app_code = 'lalu' + AND p.platform_code = 'dice' + AND NOT EXISTS ( + SELECT 1 + FROM game_catalog c + WHERE c.app_code = p.app_code + AND c.platform_code = p.platform_code + ); diff --git a/server/admin/internal/modules/gamemanagement/self_game_handler.go b/server/admin/internal/modules/gamemanagement/self_game_handler.go index 85b3d07e..ded1f34a 100644 --- a/server/admin/internal/modules/gamemanagement/self_game_handler.go +++ b/server/admin/internal/modules/gamemanagement/self_game_handler.go @@ -178,7 +178,7 @@ func (h *Handler) GenerateDiceRobots(c *gin.Context) { response.ServerError(c, "获取机器人用户资料失败") return } - h.auditLog(c, "generate-dice-robots", "game_self_game_robots", "dice", fmt.Sprintf("count=%d", len(userIDs))) + h.auditLog(c, "generate-dice-robots", "game_self_game_robots", "universal", fmt.Sprintf("count=%d", len(userIDs))) response.OK(c, gin.H{"created": len(userIDs), "items": items}) } diff --git a/services/game-service/deploy/mysql/initdata/001_self_games.sql b/services/game-service/deploy/mysql/initdata/001_self_games.sql index c3297c4e..8a03257b 100644 --- a/services/game-service/deploy/mysql/initdata/001_self_games.sql +++ b/services/game-service/deploy/mysql/initdata/001_self_games.sql @@ -4,45 +4,5 @@ CREATE DATABASE IF NOT EXISTS hyapp_game DEFAULT CHARACTER SET utf8mb4 COLLATE u USE hyapp_game; --- 自研游戏数据单独维护;schema 初始化只建表,默认数据在这里保证 dice 和 rock 在新环境中同时可用。 -SET @now_ms := CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED); -SET @self_game_stakes := CAST('[{"StakeCoin":100,"Enabled":true,"SortOrder":10},{"StakeCoin":500,"Enabled":true,"SortOrder":20},{"StakeCoin":1000,"Enabled":true,"SortOrder":30},{"StakeCoin":8000,"Enabled":true,"SortOrder":40}]' AS JSON); - -INSERT INTO game_platforms ( - app_code, platform_code, platform_name, status, api_base_url, adapter_type, - callback_secret_ciphertext, callback_ip_whitelist, adapter_config, - sort_order, created_at_ms, updated_at_ms -) VALUES ( - 'lalu', 'dice', 'Dice Internal', 'active', '', 'dice_internal', - '', CAST('[]' AS JSON), CAST('{}' AS JSON), - 0, @now_ms, @now_ms -) ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms; - -INSERT INTO game_catalog ( - app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url, - launch_mode, orientation, safe_height, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms -) VALUES - ('lalu', 'dice', 'dice', 'dice', 'Dice', 'self', '', '', 'h5_popup', 'portrait', 0, 0, 'active', 0, CAST('["self_game"]' AS JSON), @now_ms, @now_ms), - ('lalu', 'rock', 'dice', 'rock', 'Rock Paper Scissors', 'self', '', '', 'h5_popup', 'portrait', 0, 0, 'active', 1, CAST('["self_game"]' AS JSON), @now_ms, @now_ms) -ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms; - -INSERT INTO game_display_rules ( - app_code, rule_id, game_id, scene, region_id, language, platform, visible, enabled, sort_order, created_at_ms, updated_at_ms -) VALUES - ('lalu', 'dice_voice_default', 'dice', 'voice_room', 0, '', '', 1, 1, 0, @now_ms, @now_ms), - ('lalu', 'rock_voice_default', 'rock', 'voice_room', 0, '', '', 1, 1, 1, @now_ms, @now_ms) -ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms; - -INSERT INTO game_self_game_configs ( - app_code, game_id, status, stake_options_json, fee_bps, pool_bps, - min_players, max_players, robot_enabled, robot_match_wait_ms, created_at_ms, updated_at_ms -) VALUES - ('lalu', 'dice', 'active', @self_game_stakes, 500, 100, 2, 2, 1, 1000, @now_ms, @now_ms), - ('lalu', 'rock', 'active', @self_game_stakes, 500, 100, 2, 2, 1, 1000, @now_ms, @now_ms) -ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms; - -INSERT INTO game_self_game_pools (app_code, game_id, balance_coin, created_at_ms, updated_at_ms) -VALUES - ('lalu', 'dice', 0, @now_ms, @now_ms), - ('lalu', 'rock', 0, @now_ms, @now_ms) -ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms; +-- 自研游戏不再随数据库初始化自动写入;后台需要开放某个自研游戏时,应通过后台配置显式创建。 +SET @no_default_self_games := 1; diff --git a/services/game-service/internal/storage/mysql/dice_matchmaking_repository.go b/services/game-service/internal/storage/mysql/dice_matchmaking_repository.go index 140db81f..1622112b 100644 --- a/services/game-service/internal/storage/mysql/dice_matchmaking_repository.go +++ b/services/game-service/internal/storage/mysql/dice_matchmaking_repository.go @@ -267,7 +267,9 @@ func (r *Repository) CancelDiceMatch(ctx context.Context, appCode string, matchI return r.GetDiceMatch(ctx, app, match.MatchID) } -// PickActiveDiceRobot 从当前游戏的可用机器人池里随机挑选一个未加入本局的机器人。 +// PickActiveDiceRobot 从当前 App 的通用机器人池里随机挑选一个未加入本局的机器人。 +// game_self_game_robots 仍保留 game_id 字段是为了兼容旧数据和后台接口,但机器人账号本身不再按 dice/rock 隔离; +// 只要同一个 app_code 下存在 active 登记,这个真实用户就可以给任意自研游戏补位。 func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string) (dicedomain.Robot, error) { app := appcode.Normalize(appCode) gameID = normalizeDiceGameID(gameID) @@ -275,13 +277,17 @@ func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, ga var candidateCount int64 if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) - FROM game_self_game_robots r - WHERE r.app_code = ? AND r.game_id = ? AND r.status = ? - AND NOT EXISTS ( - SELECT 1 FROM game_dice_participants p - WHERE p.app_code = r.app_code AND p.match_id = ? AND p.user_id = r.user_id - )`, - app, gameID, dicedomain.RobotStatusActive, matchID, + FROM ( + SELECT r.user_id + FROM game_self_game_robots r + WHERE r.app_code = ? AND r.status = ? + AND NOT EXISTS ( + SELECT 1 FROM game_dice_participants p + WHERE p.app_code = r.app_code AND p.match_id = ? AND p.user_id = r.user_id + ) + GROUP BY r.user_id + ) candidates`, + app, dicedomain.RobotStatusActive, matchID, ).Scan(&candidateCount); err != nil { return dicedomain.Robot{}, err } @@ -294,16 +300,18 @@ func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, ga } var robot dicedomain.Robot err = r.db.QueryRowContext(ctx, - `SELECT r.app_code, r.game_id, r.user_id, r.status, r.created_by_admin_id, r.created_at_ms, r.updated_at_ms + `SELECT r.app_code, ? AS game_id, r.user_id, ? AS status, + MIN(r.created_by_admin_id), MIN(r.created_at_ms), MAX(r.updated_at_ms) FROM game_self_game_robots r - WHERE r.app_code = ? AND r.game_id = ? AND r.status = ? + WHERE r.app_code = ? AND r.status = ? AND NOT EXISTS ( SELECT 1 FROM game_dice_participants p WHERE p.app_code = r.app_code AND p.match_id = ? AND p.user_id = r.user_id ) - ORDER BY r.updated_at_ms ASC, r.user_id ASC + GROUP BY r.app_code, r.user_id + ORDER BY MAX(r.updated_at_ms) ASC, r.user_id ASC LIMIT 1 OFFSET ?`, - app, gameID, dicedomain.RobotStatusActive, matchID, randomOffset, + gameID, dicedomain.RobotStatusActive, app, dicedomain.RobotStatusActive, matchID, randomOffset, ).Scan(&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, &robot.CreatedAtMS, &robot.UpdatedAtMS) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -413,9 +421,9 @@ func (r *Repository) AdjustDicePool(ctx context.Context, adjustment dicedomain.P if _, err := tx.ExecContext(ctx, ` INSERT INTO game_outbox ( app_code, event_id, event_type, order_id, user_id, platform_code, game_id, - op_type, coin_amount, payload_json, status, attempts, next_retry_at_ms, - created_at_ms, updated_at_ms, published_at_ms - ) VALUES (?, ?, ?, '', ?, 'self', ?, '', 0, CAST(? AS JSON), 'pending', 0, 0, ?, ?, 0) + op_type, coin_amount, payload_json, status, worker_id, lock_until_ms, + retry_count, next_retry_at_ms, last_error, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, '', ?, 'self', ?, '', 0, CAST(? AS JSON), 'pending', '', 0, 0, 0, '', ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)`, adjustment.AppCode, gamemq.EventTypeSelfGamePoolAdjusted+":"+adjustment.AdjustmentID, gamemq.EventTypeSelfGamePoolAdjusted, adjustment.UserID, adjustment.GameID, string(payload), adjustment.CreatedAtMS, adjustment.CreatedAtMS, @@ -439,19 +447,27 @@ func (r *Repository) ListDiceRobots(ctx context.Context, appCode string, gameID if strings.TrimSpace(cursor) != "" { _, _ = fmt.Sscan(strings.TrimSpace(cursor), &cursorID) } - args := []any{app, gameID} + // 机器人后台展示的是“账号池”而不是“游戏池”;同一个 user_id 即使历史上登记过多个 game_id,也只展示一行。 + // 聚合状态按 active 优先,避免旧的 dice active + rock disabled 脏数据让同一个账号在列表里出现两种状态。 + args := []any{gameID, dicedomain.RobotStatusActive, dicedomain.RobotStatusActive, dicedomain.RobotStatusDisabled, app} query := `SELECT app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms - FROM game_self_game_robots - WHERE app_code = ? AND game_id = ?` - if status != "" { - query += ` AND status = ?` - args = append(args, status) - } + FROM ( + SELECT app_code, ? AS game_id, user_id, + IF(SUM(status = ?) > 0, ?, ?) AS status, + MIN(created_by_admin_id) AS created_by_admin_id, + MIN(created_at_ms) AS created_at_ms, + MAX(updated_at_ms) AS updated_at_ms + FROM game_self_game_robots + WHERE app_code = ?` if cursorID > 0 { query += ` AND user_id > ?` args = append(args, cursorID) } - query += ` ORDER BY user_id ASC LIMIT ?` + query += ` GROUP BY app_code, user_id + ) robots + WHERE (? = '' OR status = ?) + ORDER BY user_id ASC LIMIT ?` + args = append(args, status, status) args = append(args, int(pageSize)+1) rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { @@ -581,7 +597,8 @@ func (r *Repository) ListExploreWinners(ctx context.Context, appCode string, pag func (r *Repository) RegisterDiceRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64, nowMs int64) ([]dicedomain.Robot, error) { app := appcode.Normalize(appCode) - gameID = normalizeDiceGameID(gameID) + // 新生成的机器人只写入默认 game_id 行;匹配和后台管理都已经按 app_code + user_id 聚合,这行只是兼容当前表主键。 + gameID = dicedomain.DefaultGameID tx, err := r.db.BeginTx(ctx, nil) if err != nil { return nil, err @@ -601,7 +618,7 @@ func (r *Repository) RegisterDiceRobots(ctx context.Context, appCode string, gam if err := tx.Commit(); err != nil { return nil, err } - robots, _, err := r.ListDiceRobots(ctx, app, gameID, "", 200, "") + robots, _, err := r.ListDiceRobots(ctx, app, "", "", 200, "") return robots, err } @@ -615,8 +632,8 @@ func (r *Repository) SetDiceRobotStatus(ctx context.Context, appCode string, gam res, err := r.db.ExecContext(ctx, `UPDATE game_self_game_robots SET status = ?, updated_at_ms = ? - WHERE app_code = ? AND game_id = ? AND user_id = ?`, - status, nowMs, app, gameID, userID, + WHERE app_code = ? AND user_id = ?`, + status, nowMs, app, userID, ) if err != nil { return dicedomain.Robot{}, err @@ -625,24 +642,17 @@ func (r *Repository) SetDiceRobotStatus(ctx context.Context, appCode string, gam if affected == 0 { return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found") } - var robot dicedomain.Robot - err = r.db.QueryRowContext(ctx, - `SELECT app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms - FROM game_self_game_robots - WHERE app_code = ? AND game_id = ? AND user_id = ?`, - app, gameID, userID, - ).Scan(&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, &robot.CreatedAtMS, &robot.UpdatedAtMS) - return robot, err + return r.getUniversalDiceRobot(ctx, app, gameID, userID) } // DeleteDiceRobot 只删除机器人登记关系,不删除 user-service 主账号,避免历史对局和钱包流水失去用户归属。 +// 删除按 user_id 清理所有历史 game_id 行,保证后台“不区分 dice/rock”的操作不会留下另一个游戏的残余登记。 func (r *Repository) DeleteDiceRobot(ctx context.Context, appCode string, gameID string, userID int64) error { app := appcode.Normalize(appCode) - gameID = normalizeDiceGameID(gameID) res, err := r.db.ExecContext(ctx, `DELETE FROM game_self_game_robots - WHERE app_code = ? AND game_id = ? AND user_id = ?`, - app, gameID, userID, + WHERE app_code = ? AND user_id = ?`, + app, userID, ) if err != nil { return err @@ -654,6 +664,28 @@ func (r *Repository) DeleteDiceRobot(ctx context.Context, appCode string, gameID return nil } +func (r *Repository) getUniversalDiceRobot(ctx context.Context, appCode string, gameID string, userID int64) (dicedomain.Robot, error) { + app := appcode.Normalize(appCode) + gameID = normalizeDiceGameID(gameID) + var robot dicedomain.Robot + err := r.db.QueryRowContext(ctx, + `SELECT app_code, ? AS game_id, user_id, + IF(SUM(status = ?) > 0, ?, ?) AS status, + MIN(created_by_admin_id), MIN(created_at_ms), MAX(updated_at_ms) + FROM game_self_game_robots + WHERE app_code = ? AND user_id = ? + GROUP BY app_code, user_id`, + gameID, dicedomain.RobotStatusActive, dicedomain.RobotStatusActive, dicedomain.RobotStatusDisabled, app, userID, + ).Scan(&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, &robot.CreatedAtMS, &robot.UpdatedAtMS) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found") + } + return dicedomain.Robot{}, err + } + return robot, nil +} + func insertDiceParticipantLocked(ctx context.Context, tx *sql.Tx, match dicedomain.Match, userID int64, participantType string, rpsGesture string, nowMs int64) (dicedomain.Match, error) { var seatNo int32 if err := tx.QueryRowContext(ctx, diff --git a/services/game-service/internal/storage/mysql/repository_test.go b/services/game-service/internal/storage/mysql/repository_test.go index aa0f85e4..9a1de4dc 100644 --- a/services/game-service/internal/storage/mysql/repository_test.go +++ b/services/game-service/internal/storage/mysql/repository_test.go @@ -216,6 +216,133 @@ func TestPickActiveDiceRobotSelectsRandomCandidateInsteadOfFixedFirstRow(t *test if len(picked) < 2 { t.Fatalf("robot picking still behaves like fixed first-row selection, got picked user ids: %+v", picked) } + + rockRobot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.SelfGameIDRock, "match_random_robot_rock") + if err != nil { + t.Fatalf("rock should reuse the app-wide robot pool: %v", err) + } + if rockRobot.GameID != dicedomain.SelfGameIDRock || !containsInt64(userIDs, rockRobot.UserID) { + t.Fatalf("rock robot should come from the shared app robot pool, got %+v", rockRobot) + } +} + +func TestUniversalDiceRobotManagementIgnoresGameID(t *testing.T) { + caller := mysqlschema.CallerFile(t, 1) + schema := mysqlschema.New(t, mysqlschema.Config{ + InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"), + DatabasePrefix: "hy_game_test", + }) + + ctx := appcode.WithContext(context.Background(), "lalu") + repo, err := Open(ctx, schema.DSN) + if err != nil { + t.Fatalf("open repository failed: %v", err) + } + t.Cleanup(func() { _ = repo.Close() }) + + nowMS := int64(1780542672000) + rows := []struct { + gameID string + userID int64 + status string + }{ + {gameID: dicedomain.DefaultGameID, userID: 201, status: dicedomain.RobotStatusActive}, + {gameID: dicedomain.SelfGameIDRock, userID: 201, status: dicedomain.RobotStatusDisabled}, + {gameID: dicedomain.SelfGameIDRock, userID: 202, status: dicedomain.RobotStatusDisabled}, + } + for index, row := range rows { + if _, err := repo.db.ExecContext(ctx, + `INSERT INTO game_self_game_robots ( + app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, 7, ?, ?)`, + "lalu", row.gameID, row.userID, row.status, nowMS+int64(index), nowMS+int64(index), + ); err != nil { + t.Fatalf("insert robot row %+v failed: %v", row, err) + } + } + + active, _, err := repo.ListDiceRobots(ctx, "lalu", "", dicedomain.RobotStatusActive, 20, "") + if err != nil { + t.Fatalf("list active robots failed: %v", err) + } + if len(active) != 1 || active[0].UserID != 201 || active[0].Status != dicedomain.RobotStatusActive { + t.Fatalf("active list should aggregate user 201 once, got %+v", active) + } + disabled, _, err := repo.ListDiceRobots(ctx, "lalu", "", dicedomain.RobotStatusDisabled, 20, "") + if err != nil { + t.Fatalf("list disabled robots failed: %v", err) + } + if len(disabled) != 1 || disabled[0].UserID != 202 || disabled[0].Status != dicedomain.RobotStatusDisabled { + t.Fatalf("disabled list should only include fully disabled users, got %+v", disabled) + } + + updated, err := repo.SetDiceRobotStatus(ctx, "lalu", dicedomain.SelfGameIDRock, 201, dicedomain.RobotStatusDisabled, nowMS+100) + if err != nil { + t.Fatalf("set universal robot status failed: %v", err) + } + if updated.GameID != dicedomain.SelfGameIDRock || updated.Status != dicedomain.RobotStatusDisabled { + t.Fatalf("status response should keep requested game context while updating shared rows, got %+v", updated) + } + var activeRows int + if err := repo.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM game_self_game_robots WHERE app_code = ? AND user_id = ? AND status = ?`, + "lalu", int64(201), dicedomain.RobotStatusActive, + ).Scan(&activeRows); err != nil { + t.Fatalf("query active rows failed: %v", err) + } + if activeRows != 0 { + t.Fatalf("status update should affect all game_id rows for user 201, active rows=%d", activeRows) + } + + if err := repo.DeleteDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, 201); err != nil { + t.Fatalf("delete universal robot failed: %v", err) + } + var remainingRows int + if err := repo.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM game_self_game_robots WHERE app_code = ? AND user_id = ?`, + "lalu", int64(201), + ).Scan(&remainingRows); err != nil { + t.Fatalf("query remaining rows failed: %v", err) + } + if remainingRows != 0 { + t.Fatalf("delete should remove every game_id row for user 201, rows=%d", remainingRows) + } + + registered, err := repo.RegisterDiceRobots(ctx, "lalu", dicedomain.SelfGameIDRock, []int64{203}, 7, nowMS+200) + if err != nil { + t.Fatalf("register universal robot failed: %v", err) + } + if !robotListContains(registered, 203, dicedomain.RobotStatusActive) { + t.Fatalf("registered robot should be returned from shared list, got %+v", registered) + } + var registeredGameID string + if err := repo.db.QueryRowContext(ctx, + `SELECT game_id FROM game_self_game_robots WHERE app_code = ? AND user_id = ?`, + "lalu", int64(203), + ).Scan(®isteredGameID); err != nil { + t.Fatalf("query registered robot failed: %v", err) + } + if registeredGameID != dicedomain.DefaultGameID { + t.Fatalf("new universal robots should use default compatibility game_id, got %s", registeredGameID) + } +} + +func containsInt64(values []int64, target int64) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func robotListContains(robots []dicedomain.Robot, userID int64, status string) bool { + for _, robot := range robots { + if robot.UserID == userID && robot.Status == status { + return true + } + } + return false } func TestUpsertCatalogCreatesDefaultVoiceRoomDisplayRule(t *testing.T) {