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/10] 连接源数据库... ") srcDB, err := gorm.Open(mysql.Open(*srcDSN), gormCfg) if err != nil { log.Fatalf("源库连接失败: %v", err) } fmt.Println("OK") // 连接目标库 fmt.Print("[2/10] 连接目标数据库... ") dstDB, err := gorm.Open(mysql.Open(*dstDSN), gormCfg) if err != nil { log.Fatalf("目标库连接失败: %v", err) } fmt.Println("OK") // ── Step 3: 查询付费用户 ID ── fmt.Print("[3/10] 查询付费用户... ") 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 UNION SELECT user_id AS uid FROM user_subscribe WHERE user_id > 0 AND (expire_time IS NULL OR expire_time > NOW()) ) t INNER JOIN user u ON u.id = t.uid WHERE u.id NOT IN ( SELECT user_id FROM user_auth_methods WHERE auth_type = 'email' AND auth_identifier = 'devneeds52@gmail.com' ) 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/10] 读取源库数据... ") 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) // ── 处理多订阅:如果用户有多个订阅,仅保留未过期的 ── nowTime := time.Now() subByUser := make(map[int64][]UserSubscribe) for _, s := range subscribes { subByUser[s.UserId] = append(subByUser[s.UserId], s) } var validSubscribes []UserSubscribe for _, subs := range subByUser { if len(subs) <= 1 { // 单个订阅直接保留 validSubscribes = append(validSubscribes, subs...) continue } var unexpired []UserSubscribe var latest *UserSubscribe for i := range subs { s := subs[i] // 如果没有过期时间,或者过期时间在当前时间之后 if s.ExpireTime == nil || s.ExpireTime.After(nowTime) { unexpired = append(unexpired, s) } // 记录到期时间最晚的一个,以防全部都过期了 if latest == nil { latest = &s } else if latest.ExpireTime != nil && s.ExpireTime != nil && s.ExpireTime.After(*latest.ExpireTime) { latest = &s } else if latest.ExpireTime != nil && s.ExpireTime == nil { latest = &s } } if len(unexpired) > 0 { // 存在未过期的订阅,仅保留所有未过期的 validSubscribes = append(validSubscribes, unexpired...) } else if latest != nil { // 如果全部过期,仅保留到期时间最晚的那一个 validSubscribes = append(validSubscribes, *latest) } } subscribes = validSubscribes 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() // ── ID 重建:将所有记录主键从 1 开始连续重赋值,并同步更新所有外键 ── fmt.Println(" 重建 ID...") // 各表 old→new 映射(仅对有外键引用的表建立映射) userIDMap := make(map[int64]int64, len(users)) orderIDMap := make(map[int64]int64, len(orders)) subPlanIDMap := make(map[int64]int64, len(subPlans)) paymentIDMap := make(map[int64]int64, len(payments)) deviceIDMap := make(map[int64]int64, len(devices)) // 1. 重建 user ID(从 1 开始连续) for i := range users { newID := int64(i + 1) userIDMap[users[i].Id] = newID users[i].Id = newID } // 2. 重建 subscribe 配置表 ID for i := range subPlans { newID := int64(i + 1) subPlanIDMap[subPlans[i].Id] = newID subPlans[i].Id = newID } // 3. 重建 payment 配置表 ID for i := range payments { newID := int64(i + 1) paymentIDMap[payments[i].Id] = newID payments[i].Id = newID } // 4. 重建 auth_methods ID + 更新 user_id 外键 for i := range auths { auths[i].Id = int64(i + 1) if v, ok := userIDMap[auths[i].UserId]; ok { auths[i].UserId = v } } // 5. 重建 device ID + 更新 user_id 外键 for i := range devices { newID := int64(i + 1) deviceIDMap[devices[i].Id] = newID devices[i].Id = newID if v, ok := userIDMap[devices[i].UserId]; ok { devices[i].UserId = v } } // 6. 重建 order ID + 外键(user_id / subscription_user_id / payment_id / subscribe_id) for i := range orders { newID := int64(i + 1) orderIDMap[orders[i].Id] = newID orders[i].Id = newID if v, ok := userIDMap[orders[i].UserId]; ok { orders[i].UserId = v } if orders[i].SubscriptionUserId > 0 { if v, ok := userIDMap[orders[i].SubscriptionUserId]; ok { orders[i].SubscriptionUserId = v } } if orders[i].PaymentId > 0 { if v, ok := paymentIDMap[orders[i].PaymentId]; ok { orders[i].PaymentId = v } } if orders[i].SubscribeId > 0 { if v, ok := subPlanIDMap[orders[i].SubscribeId]; ok { orders[i].SubscribeId = v } } } // 二次处理 order.ParentId(父子订单指向同表,需在 orderIDMap 完整建立后再处理) for i := range orders { if orders[i].ParentId > 0 { if v, ok := orderIDMap[orders[i].ParentId]; ok { orders[i].ParentId = v } } } // 7. 重建 user_subscribe ID + 外键 for i := range subscribes { subscribes[i].Id = int64(i + 1) if v, ok := userIDMap[subscribes[i].UserId]; ok { subscribes[i].UserId = v } if subscribes[i].OrderId > 0 { if v, ok := orderIDMap[subscribes[i].OrderId]; ok { subscribes[i].OrderId = v } } if subscribes[i].SubscribeId > 0 { if v, ok := subPlanIDMap[subscribes[i].SubscribeId]; ok { subscribes[i].SubscribeId = v } } } // 8. 重建 iap ID + 更新 user_id 外键 for i := range iaps { iaps[i].Id = int64(i + 1) if v, ok := userIDMap[iaps[i].UserId]; ok { iaps[i].UserId = v } } // 9. 更新 paidIDs(Step 8 家庭组创建使用新 user ID) for i, uid := range paidIDs { if v, ok := userIDMap[uid]; ok { paidIDs[i] = v } } // 10. 更新 splits 中的 OwnerUID 和 Device 副本 // Device 是值拷贝,需通过 deviceIDMap 单独更新;AuthMethod 是指针,已随 auths[i] 同步 for i := range splits { if v, ok := userIDMap[splits[i].OwnerUID]; ok { splits[i].OwnerUID = v } if v, ok := deviceIDMap[splits[i].Device.Id]; ok { splits[i].Device.Id = v } if v, ok := userIDMap[splits[i].Device.UserId]; ok { splits[i].Device.UserId = v } } fmt.Printf(" OK — user:%d auth:%d device:%d order:%d subscribe:%d iap:%d\n", len(userIDMap), len(auths), len(deviceIDMap), len(orderIDMap), len(subscribes), len(iaps)) // ── 注入默认管理员用户(devneeds52@gmail.com) ── // 该用户不涉及付费订单,ID 紧接在已迁移用户之后,避免冲突 { defaultCreatedAt := time.Date(2025, 9, 30, 9, 33, 45, 780_000_000, time.UTC) lastLogin := time.Date(2026, 3, 15, 17, 13, 45, 0, time.UTC) defaultUID := int64(len(users) + 1) defaultUser := User{ Id: defaultUID, Password: "$pbkdf2-sha512$kyFSMS4eAnupW7bX$38953ce0e7ec8415c39603bdc3010050ddab2e433f0383222215bbec013450e3", Algo: "default", Salt: "default", Avatar: "", Balance: 0, ReferCode: "uuEPXVjS", Commission: 0, ReferralPercentage: 0, OnlyFirstPurchase: boolPtr(true), GiftAmount: 0, Enable: boolPtr(true), IsAdmin: boolPtr(true), EnableBalanceNotify: boolPtr(false), EnableLoginNotify: boolPtr(false), EnableSubscribeNotify: boolPtr(false), EnableTradeNotify: boolPtr(false), LastLoginTime: &lastLogin, MemberStatus: "", Remark: "", CreatedAt: defaultCreatedAt, UpdatedAt: time.Now(), } users = append(users, defaultUser) defaultAuth := AuthMethod{ Id: int64(len(auths) + 1), UserId: defaultUID, AuthType: "email", AuthIdentifier: "devneeds52@gmail.com", Verified: boolPtr(true), CreatedAt: defaultCreatedAt, UpdatedAt: defaultCreatedAt, } auths = append(auths, defaultAuth) fmt.Printf(" 注入管理员: uid=%d email=devneeds52@gmail.com\n", defaultUID) } fmt.Println() if *dryRun { fmt.Println("[DRY-RUN] 仅分析,不写入目标库") return } // ── Step 5: 清空目标库(可选) ── if *clean { fmt.Print("[5/10] 清空目标库... ") 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/10] 跳过清空(未指定 -clean)") } // ── Step 6: 写入全量配置表 ── fmt.Print("[6/10] 写入全量配置表... ") 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/10] 写入付费用户数据... ") 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/10] 创建家庭组... ") now := time.Now() familyCount := 0 // ── 为了配合基于新 UID 的条件检查,预先构建映射 ── deviceCountByNewUID := make(map[int64]int) for i := range devices { deviceCountByNewUID[devices[i].UserId]++ } hasEmailByNewUID := make(map[int64]bool) for i := range auths { if auths[i].AuthType == "email" { hasEmailByNewUID[auths[i].UserId] = true } } err = dstDB.Transaction(func(tx *gorm.DB) error { for _, uid := range paidIDs { // 只为多设备且有邮箱的用户创建家庭组 if deviceCountByNewUID[uid] <= 1 || !hasEmailByNewUID[uid] { continue } 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/10] 多设备拆分... ") 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) } } // 仅仅当原用户有邮箱时,才尝试将其加入家庭组(无邮箱的仅拆分为独立用户) if hasEmailByNewUID[s.OwnerUID] { // 4. 查找原用户的家庭组(如果不存在则创建,虽然理论上 Step 8 已经为多设备用户创建了) var family UserFamily if err := tx.Where("owner_user_id = ?", s.OwnerUID).First(&family).Error; err != nil { if err == gorm.ErrRecordNotFound { // 补救措施:为该用户创建一个家庭组 family = UserFamily{ OwnerUserId: s.OwnerUID, MaxMembers: defaultFamilyMaxSize, Status: 1, CreatedAt: now, UpdatedAt: now, } if err := tx.Create(&family).Error; err != nil { return fmt.Errorf("创建家庭组补救失败(owner=%d): %w", s.OwnerUID, err) } // 创建家主成员 ownerMember := UserFamilyMember{ FamilyId: family.Id, UserId: s.OwnerUID, Role: familyRoleOwner, Status: 1, JoinSource: "migration_split_recovery", JoinedAt: now, CreatedAt: now, UpdatedAt: now, } if err := tx.Create(&ownerMember).Error; err != nil { return fmt.Errorf("创建家主成员补救失败(owner=%d): %w", s.OwnerUID, err) } familyCount++ // 更新计数器 } else { 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) // ── Step 10: 修复各表 AUTO_INCREMENT ── // 确保迁移后新写入的记录不会触发主键冲突 fmt.Print("[10/10] 修复 AUTO_INCREMENT... ") type autoIncTable struct { table string // 表名(不含反引号) quoted string // SQL 中使用的表名(含反引号) } autoIncTables := []autoIncTable{ {"user", "`user`"}, {"user_auth_methods", "`user_auth_methods`"}, {"user_device", "`user_device`"}, {"order", "`order`"}, {"user_subscribe", "`user_subscribe`"}, {"apple_iap_transactions", "`apple_iap_transactions`"}, {"user_family", "`user_family`"}, {"user_family_member", "`user_family_member`"}, } for _, t := range autoIncTables { var maxID int64 dstDB.Raw(fmt.Sprintf("SELECT COALESCE(MAX(id), 0) FROM %s", t.quoted)).Scan(&maxID) nextID := maxID + 1 if err := dstDB.Exec(fmt.Sprintf("ALTER TABLE %s AUTO_INCREMENT = %d", t.quoted, nextID)).Error; err != nil { log.Printf(" 警告: 修复 %s AUTO_INCREMENT 失败: %v", t.table, err) } else { fmt.Printf("\n %-30s MAX(id)=%-8d AUTO_INCREMENT→%d", t.table, maxID, nextID) } } fmt.Println("\nOK") // ── 结果 ── 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 }