381 lines
13 KiB
Go
381 lines
13 KiB
Go
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
|
||
}
|