package main import ( "flag" "fmt" "log" "os" "time" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) // ── 模型定义(与项目一致) ── type User struct { Id int64 `gorm:"primaryKey"` Password string `gorm:"type:varchar(255)"` Algo string `gorm:"type:varchar(255)"` Salt string `gorm:"type:varchar(255)"` Avatar string `gorm:"type:varchar(255)"` Balance int64 `gorm:"type:int"` ReferCode string `gorm:"type:varchar(255)"` RefererId int64 `gorm:"type:bigint"` Commission int64 `gorm:"type:int"` ReferralPercentage int64 `gorm:"type:int"` OnlyFirstPurchase *bool `gorm:"type:tinyint(1)"` GiftAmount int64 `gorm:"type:int"` Enable *bool `gorm:"type:tinyint(1)"` IsAdmin *bool `gorm:"type:tinyint(1)"` EnableBalanceNotify *bool `gorm:"type:tinyint(1)"` EnableLoginNotify *bool `gorm:"type:tinyint(1)"` EnableSubscribeNotify *bool `gorm:"type:tinyint(1)"` EnableTradeNotify *bool `gorm:"type:tinyint(1)"` LastLoginTime *time.Time MemberStatus string `gorm:"type:varchar(255)"` Remark string `gorm:"type:text"` CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `gorm:"index"` } func (User) TableName() string { return "user" } type AuthMethod struct { Id int64 `gorm:"primaryKey"` UserId int64 `gorm:"type:bigint"` AuthType string `gorm:"type:varchar(50)"` AuthIdentifier string `gorm:"type:varchar(255)"` Verified *bool `gorm:"type:tinyint(1)"` CreatedAt time.Time UpdatedAt time.Time } func (AuthMethod) TableName() string { return "user_auth_methods" } type Device struct { Id int64 `gorm:"primaryKey"` Ip string `gorm:"type:varchar(255)"` UserId int64 `gorm:"type:bigint"` UserAgent string `gorm:"type:text"` Identifier string `gorm:"type:varchar(255)"` ShortCode string `gorm:"type:varchar(50)"` Online *bool `gorm:"type:tinyint(1)"` Enabled *bool `gorm:"type:tinyint(1)"` CreatedAt time.Time UpdatedAt time.Time } func (Device) TableName() string { return "user_device" } type UserSubscribe struct { Id int64 `gorm:"primaryKey"` UserId int64 `gorm:"type:bigint"` OrderId int64 `gorm:"type:bigint"` SubscribeId int64 `gorm:"type:bigint"` StartTime time.Time `gorm:"type:datetime(3)"` ExpireTime *time.Time `gorm:"type:datetime(3)"` FinishedAt *time.Time `gorm:"type:datetime"` Traffic int64 `gorm:"type:bigint"` Download int64 `gorm:"type:bigint"` Upload int64 `gorm:"type:bigint"` Token string `gorm:"type:varchar(255)"` UUID string `gorm:"type:varchar(255)"` Status uint8 `gorm:"type:tinyint(1)"` Note string `gorm:"type:varchar(500)"` CreatedAt time.Time UpdatedAt time.Time } func (UserSubscribe) TableName() string { return "user_subscribe" } type Order struct { Id int64 `gorm:"primaryKey"` ParentId int64 `gorm:"type:bigint"` UserId int64 `gorm:"type:bigint"` SubscriptionUserId int64 `gorm:"type:bigint"` OrderNo string `gorm:"type:varchar(255)"` Type uint8 `gorm:"type:tinyint(1)"` Quantity int64 `gorm:"type:bigint"` Price int64 `gorm:"type:int"` Amount int64 `gorm:"type:int"` GiftAmount int64 `gorm:"type:int"` Discount int64 `gorm:"type:int"` Coupon string `gorm:"type:varchar(255)"` CouponDiscount int64 `gorm:"type:int"` Commission int64 `gorm:"type:int"` PaymentId int64 `gorm:"type:bigint"` Method string `gorm:"type:varchar(255)"` FeeAmount int64 `gorm:"type:int"` TradeNo string `gorm:"type:varchar(255)"` Status uint8 `gorm:"type:tinyint(1)"` SubscribeId int64 `gorm:"type:bigint"` SubscribeToken string `gorm:"type:varchar(255)"` AppAccountToken string `gorm:"type:varchar(36)"` IsNew bool `gorm:"type:tinyint(1)"` CreatedAt time.Time UpdatedAt time.Time } func (Order) TableName() string { return "order" } type IAPTransaction struct { Id int64 `gorm:"primaryKey"` UserId int64 `gorm:"type:bigint"` OriginalTransactionId string `gorm:"type:varchar(255)"` TransactionId string `gorm:"type:varchar(255)"` ProductId string `gorm:"type:varchar(255)"` PurchaseAt *time.Time RevocationAt *time.Time JWSHash string `gorm:"type:varchar(255)"` CreatedAt time.Time UpdatedAt time.Time } func (IAPTransaction) TableName() string { return "apple_iap_transactions" } type Subscribe struct { Id int64 `gorm:"primaryKey"` Name string `gorm:"type:varchar(255)"` Language string `gorm:"type:varchar(255)"` Description string `gorm:"type:text"` UnitPrice int64 `gorm:"type:int"` UnitTime string `gorm:"type:varchar(255)"` Discount string `gorm:"type:text"` Replacement int64 `gorm:"type:int"` Inventory int64 `gorm:"type:int"` Traffic int64 `gorm:"type:int"` SpeedLimit int64 `gorm:"type:int"` DeviceLimit int64 `gorm:"type:int"` Quota int64 `gorm:"type:int"` NewUserOnly *bool `gorm:"type:tinyint(1)"` Nodes string `gorm:"type:varchar(255)"` NodeTags string `gorm:"type:varchar(255)"` Show *bool `gorm:"type:tinyint(1)"` Sell *bool `gorm:"type:tinyint(1)"` Sort int64 `gorm:"type:int"` DeductionRatio int64 `gorm:"type:int"` AllowDeduction *bool `gorm:"type:tinyint(1)"` ResetCycle int64 `gorm:"type:int"` RenewalReset *bool `gorm:"type:tinyint(1)"` ShowOriginalPrice bool `gorm:"type:tinyint(1)"` CreatedAt time.Time UpdatedAt time.Time } func (Subscribe) TableName() string { return "subscribe" } type Payment struct { Id int64 `gorm:"primaryKey"` Name string `gorm:"type:varchar(100)"` Platform string `gorm:"type:varchar(100)"` Icon string `gorm:"type:varchar(255)"` Domain string `gorm:"type:varchar(255)"` Config string `gorm:"type:text"` Description string `gorm:"type:text"` FeeMode uint `gorm:"type:tinyint(1)"` FeePercent int64 `gorm:"type:int"` FeeAmount int64 `gorm:"type:int"` Enable *bool `gorm:"type:tinyint(1)"` Token string `gorm:"type:varchar(255)"` } func (Payment) TableName() string { return "payment" } type UserFamily struct { Id int64 `gorm:"primaryKey"` OwnerUserId int64 `gorm:"uniqueIndex"` MaxMembers int64 `gorm:"default:3"` Status uint8 `gorm:"type:tinyint(1);default:1"` CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `gorm:"index"` } func (UserFamily) TableName() string { return "user_family" } type UserFamilyMember struct { Id int64 `gorm:"primaryKey"` FamilyId int64 UserId int64 `gorm:"uniqueIndex"` Role uint8 `gorm:"type:tinyint(1);default:2"` Status uint8 `gorm:"type:tinyint(1);default:1"` JoinSource string `gorm:"type:varchar(32)"` JoinedAt time.Time LeftAt *time.Time CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `gorm:"index"` } func (UserFamilyMember) TableName() string { return "user_family_member" } // ── 主程序 ── const ( defaultFamilyMaxSize = 3 orderStatusCompleted = 3 familyRoleOwner = 1 familyRoleMember = 2 batchSize = 100 ) func main() { srcDSN := flag.String("src", "", "源数据库 DSN,格式: user:password@tcp(host:port)/dbname") dstDSN := flag.String("dst", "", "目标数据库 DSN,格式: user:password@tcp(host:port)/dbname") clean := flag.Bool("clean", false, "导入前清空目标库相关表") dryRun := flag.Bool("dry-run", false, "仅分析不写入,打印统计信息") flag.Parse() if *srcDSN == "" || *dstDSN == "" { fmt.Println("付费用户数据迁移工具") fmt.Println() fmt.Println("用法:") fmt.Println(" go run scripts/migrate_paid_users.go \\") fmt.Println(" -src 'root:rootpassword@tcp(127.0.0.1:3306)/ppanel?charset=utf8mb4&parseTime=True&loc=Local' \\") fmt.Println(" -dst 'root:jpcV41ppanel@tcp(154.12.35.103:3306)/ppanel?charset=utf8mb4&parseTime=True&loc=Local' \\") fmt.Println(" -clean") fmt.Println() fmt.Println("参数:") fmt.Println(" -src 源数据库 DSN(旧备份库)") fmt.Println(" -dst 目标数据库 DSN(新线上库)") fmt.Println(" -clean 导入前清空目标库的用户/订单等表(保留表结构)") fmt.Println(" -dry-run 仅分析不写入") os.Exit(1) } gormCfg := &gorm.Config{ Logger: logger.Default.LogMode(logger.Warn), } // 连接源库 fmt.Println("=== 付费用户数据迁移 ===") fmt.Println() fmt.Print("[1/9] 连接源数据库... ") srcDB, err := gorm.Open(mysql.Open(*srcDSN), gormCfg) if err != nil { log.Fatalf("源库连接失败: %v", err) } fmt.Println("OK") // 连接目标库 fmt.Print("[2/9] 连接目标数据库... ") dstDB, err := gorm.Open(mysql.Open(*dstDSN), gormCfg) if err != nil { log.Fatalf("目标库连接失败: %v", err) } fmt.Println("OK") // ── Step 3: 查询付费用户 ID ── fmt.Print("[3/9] 查询付费用户... ") var paidIDs []int64 err = srcDB.Raw(` SELECT DISTINCT t.uid FROM ( SELECT user_id AS uid FROM ` + "`order`" + ` WHERE status = ? AND user_id > 0 UNION SELECT user_id AS uid FROM apple_iap_transactions WHERE user_id > 0 ) t INNER JOIN user u ON u.id = t.uid ORDER BY t.uid `, orderStatusCompleted).Scan(&paidIDs).Error if err != nil { log.Fatalf("查询付费用户失败: %v", err) } fmt.Printf("%d 个付费用户\n", len(paidIDs)) if len(paidIDs) == 0 { fmt.Println("没有找到付费用户,退出") return } // ── Step 4: 读取源库数据 ── fmt.Print("[4/9] 读取源库数据... ") var ( users []User auths []AuthMethod devices []Device orders []Order subscribes []UserSubscribe iaps []IAPTransaction // 全量表 subPlans []Subscribe payments []Payment ) srcDB.Where("id IN ?", paidIDs).Find(&users) srcDB.Where("user_id IN ?", paidIDs).Find(&auths) srcDB.Where("user_id IN ?", paidIDs).Find(&devices) srcDB.Where("user_id IN ? AND status = ?", paidIDs, orderStatusCompleted).Find(&orders) srcDB.Where("user_id IN ?", paidIDs).Find(&subscribes) srcDB.Where("user_id IN ?", paidIDs).Find(&iaps) srcDB.Find(&subPlans) srcDB.Find(&payments) fmt.Println("OK") fmt.Println() fmt.Println(" 数据统计:") fmt.Printf(" user: %d\n", len(users)) fmt.Printf(" user_auth_methods: %d\n", len(auths)) fmt.Printf(" user_device: %d\n", len(devices)) fmt.Printf(" order: %d\n", len(orders)) fmt.Printf(" user_subscribe: %d\n", len(subscribes)) fmt.Printf(" apple_iap: %d\n", len(iaps)) fmt.Printf(" subscribe(全量): %d\n", len(subPlans)) fmt.Printf(" payment(全量): %d\n", len(payments)) // ── 识别多设备用户 ── deviceByUser := make(map[int64][]Device) for _, d := range devices { deviceByUser[d.UserId] = append(deviceByUser[d.UserId], d) } authByUserDevice := make(map[string]*AuthMethod) // key: "userId:identifier" for i := range auths { a := &auths[i] if a.AuthType == "device" { key := fmt.Sprintf("%d:%s", a.UserId, a.AuthIdentifier) authByUserDevice[key] = a } } type splitInfo struct { OwnerUID int64 Device Device AuthMethod *AuthMethod } var splits []splitInfo for uid, devs := range deviceByUser { if len(devs) <= 1 { continue } // 按 ID 排序:最小的保留,其余拆分 minID := devs[0].Id for _, d := range devs[1:] { if d.Id < minID { minID = d.Id } } for _, d := range devs { if d.Id == minID { continue } key := fmt.Sprintf("%d:%s", uid, d.Identifier) auth := authByUserDevice[key] splits = append(splits, splitInfo{ OwnerUID: uid, Device: d, AuthMethod: auth, }) } } fmt.Printf("\n 多设备拆分: %d 个设备 → 独立用户\n", len(splits)) fmt.Println() if *dryRun { fmt.Println("[DRY-RUN] 仅分析,不写入目标库") return } // ── Step 5: 清空目标库(可选) ── if *clean { fmt.Print("[5/9] 清空目标库... ") dstDB.Exec("SET FOREIGN_KEY_CHECKS = 0") for _, tbl := range []string{ "user", "user_auth_methods", "user_device", "`order`", "user_subscribe", "apple_iap_transactions", "user_family", "user_family_member", } { dstDB.Exec(fmt.Sprintf("TRUNCATE TABLE %s", tbl)) } dstDB.Exec("SET FOREIGN_KEY_CHECKS = 1") fmt.Println("OK") } else { fmt.Println("[5/9] 跳过清空(未指定 -clean)") } // ── Step 6: 写入全量配置表 ── fmt.Print("[6/9] 写入全量配置表... ") if len(subPlans) > 0 { dstDB.Exec("DELETE FROM subscribe") // 先清再插 for _, s := range subPlans { dstDB.Create(&s) } } if len(payments) > 0 { dstDB.Exec("DELETE FROM payment") for _, p := range payments { dstDB.Create(&p) } } fmt.Println("OK") // ── Step 7: 写入付费用户数据(事务) ── fmt.Print("[7/9] 写入付费用户数据... ") err = dstDB.Transaction(func(tx *gorm.DB) error { tx.Exec("SET FOREIGN_KEY_CHECKS = 0") if err := tx.CreateInBatches(&users, batchSize).Error; err != nil { return fmt.Errorf("写入 user 失败: %w", err) } if err := tx.CreateInBatches(&auths, batchSize).Error; err != nil { return fmt.Errorf("写入 auth_methods 失败: %w", err) } if err := tx.CreateInBatches(&devices, batchSize).Error; err != nil { return fmt.Errorf("写入 device 失败: %w", err) } if err := tx.CreateInBatches(&orders, batchSize).Error; err != nil { return fmt.Errorf("写入 order 失败: %w", err) } if err := tx.CreateInBatches(&subscribes, batchSize).Error; err != nil { return fmt.Errorf("写入 subscribe 失败: %w", err) } if err := tx.CreateInBatches(&iaps, batchSize).Error; err != nil { return fmt.Errorf("写入 iap 失败: %w", err) } tx.Exec("SET FOREIGN_KEY_CHECKS = 1") return nil }) if err != nil { log.Fatalf("写入失败: %v", err) } fmt.Println("OK") // ── Step 8: 创建家庭组 ── fmt.Print("[8/9] 创建家庭组... ") now := time.Now() familyCount := 0 err = dstDB.Transaction(func(tx *gorm.DB) error { for _, uid := range paidIDs { family := UserFamily{ OwnerUserId: uid, MaxMembers: defaultFamilyMaxSize, Status: 1, CreatedAt: now, UpdatedAt: now, } if err := tx.Create(&family).Error; err != nil { return fmt.Errorf("创建家庭组(uid=%d)失败: %w", uid, err) } member := UserFamilyMember{ FamilyId: family.Id, UserId: uid, Role: familyRoleOwner, Status: 1, JoinSource: "migration", JoinedAt: now, CreatedAt: now, UpdatedAt: now, } if err := tx.Create(&member).Error; err != nil { return fmt.Errorf("创建家主成员(uid=%d)失败: %w", uid, err) } familyCount++ } return nil }) if err != nil { log.Fatalf("家庭组创建失败: %v", err) } fmt.Printf("%d 个家庭组\n", familyCount) // ── Step 9: 多设备拆分 ── fmt.Print("[9/9] 多设备拆分... ") splitCount := 0 err = dstDB.Transaction(func(tx *gorm.DB) error { for _, s := range splits { // 1. 创建新用户 newUser := User{ Password: "", Algo: "default", Salt: "default", Enable: boolPtr(true), IsAdmin: boolPtr(false), OnlyFirstPurchase: boolPtr(true), EnableBalanceNotify: boolPtr(false), EnableLoginNotify: boolPtr(false), EnableSubscribeNotify: boolPtr(false), EnableTradeNotify: boolPtr(false), CreatedAt: s.Device.CreatedAt, UpdatedAt: now, } if err := tx.Create(&newUser).Error; err != nil { return fmt.Errorf("创建拆分用户失败(owner=%d, device=%d): %w", s.OwnerUID, s.Device.Id, err) } // 2. 转移设备到新用户 if err := tx.Model(&Device{}).Where("id = ?", s.Device.Id). Update("user_id", newUser.Id).Error; err != nil { return fmt.Errorf("转移设备失败: %w", err) } // 3. 转移 auth_method 到新用户 if s.AuthMethod != nil { if err := tx.Model(&AuthMethod{}).Where("id = ?", s.AuthMethod.Id). Update("user_id", newUser.Id).Error; err != nil { return fmt.Errorf("转移 auth_method 失败: %w", err) } } // 4. 查找原用户的家庭组 var family UserFamily if err := tx.Where("owner_user_id = ?", s.OwnerUID).First(&family).Error; err != nil { return fmt.Errorf("查找家庭组失败(owner=%d): %w", s.OwnerUID, err) } // 5. 加入家庭组 member := UserFamilyMember{ FamilyId: family.Id, UserId: newUser.Id, Role: familyRoleMember, Status: 1, JoinSource: "migration_split", JoinedAt: s.Device.CreatedAt, CreatedAt: now, UpdatedAt: now, } if err := tx.Create(&member).Error; err != nil { return fmt.Errorf("添加家庭成员失败: %w", err) } splitCount++ } return nil }) if err != nil { log.Fatalf("设备拆分失败: %v", err) } fmt.Printf("%d 个设备\n", splitCount) // ── 结果 ── fmt.Println() fmt.Println("=== 迁移完成 ===") fmt.Printf(" 用户: %d (原始) + %d (拆分) = %d\n", len(users), splitCount, len(users)+splitCount) fmt.Printf(" 家庭组: %d\n", familyCount) fmt.Printf(" 家庭成员: %d (家主) + %d (拆分) = %d\n", familyCount, splitCount, familyCount+splitCount) fmt.Printf(" 订单: %d\n", len(orders)) fmt.Printf(" 订阅: %d\n", len(subscribes)) } func boolPtr(b bool) *bool { return &b }