package main import ( "context" "crypto/sha1" "database/sql" "encoding/hex" "encoding/json" "flag" "fmt" "log" "strings" "time" _ "github.com/go-sql-driver/mysql" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/proto" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" ) const ( defaultLimit = 2000 ownerCountryRoomExtKey = "owner_country_code" ) type roomCandidate struct { AppCode string `json:"app_code"` RoomID string `json:"room_id"` RoomShortID string `json:"room_short_id"` OwnerUserID int64 `json:"owner_user_id"` VisibleRegionID int64 `json:"visible_region_id"` OwnerRegionID int64 `json:"owner_region_id"` ListOwnerCountryCode string `json:"list_owner_country_code"` SnapshotCountryCode string `json:"snapshot_country_code"` TargetOwnerCountryCode string `json:"target_owner_country_code"` NeedsListBackfill bool `json:"needs_list_backfill"` NeedsSnapshotBackfill bool `json:"needs_snapshot_backfill"` } type userCountryFact struct { CountryCode string RegionID int64 } func main() { var roomDSN string var userDSN string var roomGRPCAddr string var app string var roomID string var roomShortID string var limit int var timeout time.Duration var apply bool var continueOnError bool flag.StringVar(&roomDSN, "room_mysql_dsn", "", "room-service MySQL DSN") flag.StringVar(&userDSN, "user_mysql_dsn", "", "user-service MySQL DSN") flag.StringVar(&roomGRPCAddr, "room_grpc_addr", "", "room-service gRPC address; required with -apply") flag.StringVar(&app, "app_code", appcode.Default, "app_code scope") flag.StringVar(&roomID, "room_id", "", "optional room_id filter") flag.StringVar(&roomShortID, "room_short_id", "", "optional room_short_id filter") flag.IntVar(&limit, "limit", defaultLimit, "maximum room_list_entries rows to scan") flag.DurationVar(&timeout, "timeout", 10*time.Minute, "overall execution timeout") flag.BoolVar(&apply, "apply", false, "submit room-service mutations; omitted means dry-run") flag.BoolVar(&continueOnError, "continue_on_error", false, "log per-room failures and continue applying remaining candidates") flag.Parse() if strings.TrimSpace(roomDSN) == "" || strings.TrimSpace(userDSN) == "" { log.Fatal("room_mysql_dsn and user_mysql_dsn are required") } if apply && strings.TrimSpace(roomGRPCAddr) == "" { log.Fatal("room_grpc_addr is required with -apply") } ctx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), app), timeout) defer cancel() roomDB, err := sql.Open("mysql", roomDSN) if err != nil { log.Fatalf("open room mysql failed: %v", err) } defer roomDB.Close() roomDB.SetMaxOpenConns(4) roomDB.SetMaxIdleConns(4) roomDB.SetConnMaxLifetime(30 * time.Minute) if err := roomDB.PingContext(ctx); err != nil { log.Fatalf("ping room mysql failed: %v", err) } userDB, err := sql.Open("mysql", userDSN) if err != nil { log.Fatalf("open user mysql failed: %v", err) } defer userDB.Close() userDB.SetMaxOpenConns(4) userDB.SetMaxIdleConns(4) userDB.SetConnMaxLifetime(30 * time.Minute) if err := userDB.PingContext(ctx); err != nil { log.Fatalf("ping user mysql failed: %v", err) } candidates, err := findCandidates(ctx, roomDB, userDB, appcode.Normalize(app), roomID, roomShortID, limit) if err != nil { log.Fatalf("find candidates failed: %v", err) } encoder := json.NewEncoder(log.Writer()) for _, candidate := range candidates { if err := encoder.Encode(candidate); err != nil { log.Fatalf("write candidate failed: %v", err) } } log.Printf("candidate_count=%d apply=%t", len(candidates), apply) if !apply || len(candidates) == 0 { return } conn, err := grpc.DialContext(ctx, roomGRPCAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { log.Fatalf("dial room-service failed: %v", err) } defer conn.Close() client := roomv1.NewRoomCommandServiceClient(conn) applied := 0 failed := 0 for _, candidate := range candidates { if candidate.NeedsListBackfill && !candidate.NeedsSnapshotBackfill && candidate.SnapshotCountryCode == candidate.TargetOwnerCountryCode { // 少数历史行可能只是列表投影丢失,而 snapshot 已经持有正确 RoomExt。 // 这种情况没有状态变更可提交,直接按已验证的 snapshot 国家补列表字段,避免制造空命令。 if err := repairListCountryFromSnapshot(ctx, roomDB, candidate); err != nil { failed++ log.Printf("repair list projection room_id=%s owner_user_id=%d failed: %v", candidate.RoomID, candidate.OwnerUserID, err) if !continueOnError { log.Fatalf("stop after repair failure") } continue } if err := verifyCandidate(ctx, roomDB, candidate); err != nil { failed++ log.Printf("verify room_id=%s owner_user_id=%d failed: %v", candidate.RoomID, candidate.OwnerUserID, err) if !continueOnError { log.Fatalf("stop after verify failure") } continue } applied++ log.Printf("projected room_id=%s owner_user_id=%d country=%s", candidate.RoomID, candidate.OwnerUserID, candidate.TargetOwnerCountryCode) continue } // apply 必须走线上 room-service gRPC,让 owner forwarder 把命令转到真实 Room Cell owner; // 这样不会绕开 Redis lease,也不会被活跃房间旧内存态覆盖回去。 if err := submitOwnerCountryBackfill(ctx, client, candidate); err != nil { failed++ log.Printf("backfill room_id=%s owner_user_id=%d failed: %v", candidate.RoomID, candidate.OwnerUserID, err) if !continueOnError { log.Fatalf("stop after backfill failure") } continue } if err := verifyCandidate(ctx, roomDB, candidate); err != nil { failed++ log.Printf("verify room_id=%s owner_user_id=%d failed: %v", candidate.RoomID, candidate.OwnerUserID, err) if !continueOnError { log.Fatalf("stop after verify failure") } continue } applied++ log.Printf("applied room_id=%s owner_user_id=%d country=%s", candidate.RoomID, candidate.OwnerUserID, candidate.TargetOwnerCountryCode) } log.Printf("applied_count=%d failed_count=%d candidate_count=%d", applied, failed, len(candidates)) } func findCandidates(ctx context.Context, roomDB *sql.DB, userDB *sql.DB, app string, roomID string, roomShortID string, limit int) ([]roomCandidate, error) { if limit <= 0 { limit = defaultLimit } entries, err := listRoomEntries(ctx, roomDB, app, roomID, roomShortID, limit) if err != nil { return nil, err } result := make([]roomCandidate, 0) for _, entry := range entries { userFact, ok, err := lookupUserCountryFact(ctx, userDB, app, entry.OwnerUserID) if err != nil { return nil, err } if !ok || userFact.CountryCode == "" { // user-service 当前国家事实是本次 backfill 的唯一来源;没有明确国家时不猜测兜底。 continue } snapshotCountry, err := latestSnapshotOwnerCountry(ctx, roomDB, app, entry.RoomID) if err != nil { return nil, err } entry.SnapshotCountryCode = snapshotCountry entry.TargetOwnerCountryCode = userFact.CountryCode // 本工具只修复房主国家;UserRegionChanged 入口需要 new_region_id, // 这里显式传回房间当前 visible_region_id,避免把国家 backfill 扩大成区域迁移。 entry.OwnerRegionID = entry.VisibleRegionID entry.NeedsListBackfill = entry.ListOwnerCountryCode == "" entry.NeedsSnapshotBackfill = snapshotCountry == "" if !entry.NeedsListBackfill && !entry.NeedsSnapshotBackfill { // 这次只补空值,不主动重写已有国家码,避免把“纠偏”和“补齐”混在一个线上动作里。 continue } result = append(result, entry) } return result, nil } func listRoomEntries(ctx context.Context, roomDB *sql.DB, app string, roomID string, roomShortID string, limit int) ([]roomCandidate, error) { where := "WHERE e.app_code = ? AND e.status <> 'deleted' AND r.status <> 'deleted'" args := []any{app} if strings.TrimSpace(roomID) != "" { where += " AND e.room_id = ?" args = append(args, strings.TrimSpace(roomID)) } if strings.TrimSpace(roomShortID) != "" { where += " AND e.room_short_id = ?" args = append(args, strings.TrimSpace(roomShortID)) } args = append(args, limit) rows, err := roomDB.QueryContext(ctx, ` SELECT e.room_id, e.room_short_id, e.owner_user_id, COALESCE(e.visible_region_id, 0), COALESCE(e.owner_country_code, '') FROM room_list_entries e JOIN rooms r ON r.app_code = e.app_code AND r.room_id = e.room_id `+where+` ORDER BY e.room_id ASC LIMIT ? `, args...) if err != nil { return nil, err } defer rows.Close() entries := make([]roomCandidate, 0) for rows.Next() { var entry roomCandidate entry.AppCode = app if err := rows.Scan(&entry.RoomID, &entry.RoomShortID, &entry.OwnerUserID, &entry.VisibleRegionID, &entry.ListOwnerCountryCode); err != nil { return nil, err } entry.ListOwnerCountryCode = normalizeCountryCode(entry.ListOwnerCountryCode) entries = append(entries, entry) } return entries, rows.Err() } func lookupUserCountryFact(ctx context.Context, userDB *sql.DB, app string, userID int64) (userCountryFact, bool, error) { var country string var regionID int64 err := userDB.QueryRowContext(ctx, ` SELECT COALESCE(country, ''), COALESCE(region_id, 0) FROM users WHERE app_code = ? AND user_id = ? `, app, userID).Scan(&country, ®ionID) if err == sql.ErrNoRows { return userCountryFact{}, false, nil } if err != nil { return userCountryFact{}, false, err } return userCountryFact{CountryCode: normalizeCountryCode(country), RegionID: normalizeRegionID(regionID)}, true, nil } func latestSnapshotOwnerCountry(ctx context.Context, roomDB *sql.DB, app string, roomID string) (string, error) { var payload []byte err := roomDB.QueryRowContext(ctx, ` SELECT payload FROM room_snapshots WHERE app_code = ? AND room_id = ? `, app, roomID).Scan(&payload) if err == sql.ErrNoRows { return "", nil } if err != nil { return "", err } var snapshot roomv1.RoomSnapshot if err := proto.Unmarshal(payload, &snapshot); err != nil { return "", err } return normalizeCountryCode(snapshot.GetRoomExt()[ownerCountryRoomExtKey]), nil } func submitOwnerCountryBackfill(ctx context.Context, client roomv1.RoomCommandServiceClient, candidate roomCandidate) error { nowMS := time.Now().UTC().UnixMilli() eventID := backfillEventID(candidate) ownerCountryCode := candidate.TargetOwnerCountryCode _, err := client.AdminUpdateRoom(ctx, &roomv1.AdminUpdateRoomRequest{ Meta: &roomv1.RequestMeta{ AppCode: candidate.AppCode, RequestId: eventID, CommandId: eventID, ActorUserId: candidate.OwnerUserID, RoomId: candidate.RoomID, SentAtMs: nowMS, }, AdminName: "room-owner-country-backfill", OwnerCountryCode: &ownerCountryCode, }) return err } func verifyCandidate(ctx context.Context, roomDB *sql.DB, candidate roomCandidate) error { snapshotCountry, err := latestSnapshotOwnerCountry(ctx, roomDB, candidate.AppCode, candidate.RoomID) if err != nil { return err } if snapshotCountry != candidate.TargetOwnerCountryCode { return fmt.Errorf("snapshot owner country=%q want %q", snapshotCountry, candidate.TargetOwnerCountryCode) } var listCountry string err = roomDB.QueryRowContext(ctx, ` SELECT COALESCE(owner_country_code, '') FROM room_list_entries WHERE app_code = ? AND room_id = ? `, candidate.AppCode, candidate.RoomID).Scan(&listCountry) if err == sql.ErrNoRows { return fmt.Errorf("room_list_entries row is missing") } if err != nil { return err } if normalizeCountryCode(listCountry) != candidate.TargetOwnerCountryCode { return fmt.Errorf("list owner country=%q want %q", listCountry, candidate.TargetOwnerCountryCode) } return nil } func repairListCountryFromSnapshot(ctx context.Context, roomDB *sql.DB, candidate roomCandidate) error { result, err := roomDB.ExecContext(ctx, ` UPDATE room_list_entries SET owner_country_code = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ? AND COALESCE(owner_country_code, '') = '' `, candidate.TargetOwnerCountryCode, time.Now().UTC().UnixMilli(), candidate.AppCode, candidate.RoomID) if err != nil { return err } affected, err := result.RowsAffected() if err != nil { return err } if affected != 1 { return fmt.Errorf("projection repair affected %d rows", affected) } return nil } func backfillEventID(candidate roomCandidate) string { sum := sha1.Sum([]byte(fmt.Sprintf("%s:%s:%d:%s", candidate.AppCode, candidate.RoomID, candidate.OwnerUserID, candidate.TargetOwnerCountryCode))) return "room-owner-country-backfill:v3:" + hex.EncodeToString(sum[:]) } func normalizeCountryCode(countryCode string) string { countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) if len(countryCode) < 2 || len(countryCode) > 3 { return "" } for _, ch := range countryCode { if ch < 'A' || ch > 'Z' { return "" } } return countryCode } func normalizeRegionID(regionID int64) int64 { if regionID < 0 { return 0 } return regionID }