Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
776 lines
25 KiB
Go
776 lines
25 KiB
Go
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 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/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)
|
||
|
||
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
|
||
|
||
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/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)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
|
||
// ── 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 }
|