hi-server/scripts/migrate_paid_users.go
shanshanzhong de6fbcb518
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m22s
device_no
2026-03-18 10:28:55 -07:00

876 lines
28 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 (
"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
) t
INNER JOIN user_subscribe s ON s.user_id = t.uid
INNER JOIN user u ON u.id = t.uid
WHERE (s.expire_time IS NULL OR s.expire_time > NOW())
AND 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. 更新 paidIDsStep 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 }