2026-06-12 19:40:21 +08:00

381 lines
13 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 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, &regionID)
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
}