diff --git a/apis/types.api b/apis/types.api index 2c8a049..e53eebb 100644 --- a/apis/types.api +++ b/apis/types.api @@ -545,6 +545,7 @@ type ( Id int64 `json:"id"` Ip string `json:"ip"` Identifier string `json:"identifier"` + DeviceNo string `json:"device_no"` UserAgent string `json:"user_agent"` Online bool `json:"online"` Enabled bool `json:"enabled"` diff --git a/internal/logic/admin/user/getUserDetailLogic.go b/internal/logic/admin/user/getUserDetailLogic.go index 5373ae5..61d8b1e 100644 --- a/internal/logic/admin/user/getUserDetailLogic.go +++ b/internal/logic/admin/user/getUserDetailLogic.go @@ -35,6 +35,11 @@ func (l *GetUserDetailLogic) GetUserDetail(req *types.GetDetailRequest) (*types. return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get user detail error: %v", err.Error()) } tool.DeepCopy(&resp, userInfo) + for i, d := range userInfo.UserDevices { + if i < len(resp.UserDevices) { + resp.UserDevices[i].DeviceNo = tool.DeviceIdToHash(d.Id) + } + } if referCode := strings.TrimSpace(resp.ReferCode); referCode != "" { resp.ShareLink = logicCommon.NewInviteLinkResolver(l.ctx, l.svcCtx).ResolveInviteLink(referCode) } diff --git a/internal/logic/admin/user/getUserListLogic.go b/internal/logic/admin/user/getUserListLogic.go index e02020e..d484a5d 100644 --- a/internal/logic/admin/user/getUserListLogic.go +++ b/internal/logic/admin/user/getUserListLogic.go @@ -163,6 +163,13 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge } u.AuthMethods = authMethods + // 填充 DeviceNo + for i, d := range item.UserDevices { + if i < len(u.UserDevices) { + u.UserDevices[i].DeviceNo = tool.DeviceIdToHash(d.Id) + } + } + if relation, ok := relationMap[item.Id]; ok { u.FamilyJoined = true u.FamilyId = relation.FamilyId diff --git a/internal/logic/admin/user/getUserSubscribeDevicesLogic.go b/internal/logic/admin/user/getUserSubscribeDevicesLogic.go index 6d84f65..007d61a 100644 --- a/internal/logic/admin/user/getUserSubscribeDevicesLogic.go +++ b/internal/logic/admin/user/getUserSubscribeDevicesLogic.go @@ -34,8 +34,14 @@ func (l *GetUserSubscribeDevicesLogic) GetUserSubscribeDevices(req *types.GetUse } userRespList := make([]types.UserDevice, 0) tool.DeepCopy(&userRespList, list) + for i, d := range list { + if i < len(userRespList) { + userRespList[i].DeviceNo = tool.DeviceIdToHash(d.Id) + } + } return &types.GetUserSubscribeDevicesResponse{ Total: total, List: userRespList, }, nil } + diff --git a/internal/logic/public/user/getDeviceListLogic.go b/internal/logic/public/user/getDeviceListLogic.go index 0ace177..b1ea792 100644 --- a/internal/logic/public/user/getDeviceListLogic.go +++ b/internal/logic/public/user/getDeviceListLogic.go @@ -36,6 +36,11 @@ func (l *GetDeviceListLogic) GetDeviceList() (resp *types.GetDeviceListResponse, list, count, err := l.svcCtx.UserModel.QueryDeviceListByUserIds(l.ctx, scopeUserIds) userRespList := make([]types.UserDevice, 0) tool.DeepCopy(&userRespList, list) + for i, d := range list { + if i < len(userRespList) { + userRespList[i].DeviceNo = tool.DeviceIdToHash(d.Id) + } + } resp = &types.GetDeviceListResponse{ Total: count, List: userRespList, diff --git a/internal/logic/public/user/queryUserInfoLogic.go b/internal/logic/public/user/queryUserInfoLogic.go index 86224b5..51a49e8 100644 --- a/internal/logic/public/user/queryUserInfoLogic.go +++ b/internal/logic/public/user/queryUserInfoLogic.go @@ -44,6 +44,11 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) { return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") } tool.DeepCopy(resp, u) + for i, d := range u.UserDevices { + if i < len(resp.UserDevices) { + resp.UserDevices[i].DeviceNo = tool.DeviceIdToHash(d.Id) + } + } ownerEmailMethod := l.fillFamilyContext(resp, u.Id) var userMethods []types.UserAuthMethod diff --git a/internal/types/types.go b/internal/types/types.go index d25cf79..e7b0bb9 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -2956,6 +2956,7 @@ type UserDevice struct { Id int64 `json:"id"` Ip string `json:"ip"` Identifier string `json:"identifier"` + DeviceNo string `json:"device_no"` UserAgent string `json:"user_agent"` Online bool `json:"online"` Enabled bool `json:"enabled"` diff --git a/pkg/tool/device.go b/pkg/tool/device.go new file mode 100644 index 0000000..7860c44 --- /dev/null +++ b/pkg/tool/device.go @@ -0,0 +1,25 @@ +package tool + +import ( + "fmt" + "strings" +) + +const deviceHashSalt uint32 = 0x5A3C7E9B + +// DeviceIdToHash encodes a device id to an 8-char uppercase hex string. +// Algorithm mirrors frontend: id XOR salt → hex. +// e.g. 1 → "5A3C7E9A", 42 → "5A3C7EA1" +func DeviceIdToHash(id int64) string { + return strings.ToUpper(fmt.Sprintf("%08x", uint32(id)^deviceHashSalt)) +} + +// HashToDeviceId decodes an 8-char hex hash back to a device id. +func HashToDeviceId(hash string) (int64, error) { + var n uint32 + _, err := fmt.Sscanf(strings.ToLower(hash), "%08x", &n) + if err != nil { + return 0, err + } + return int64(n ^ deviceHashSalt), nil +} diff --git a/scripts/migrate_paid_users.go b/scripts/migrate_paid_users.go index 8fc96a8..f5e1066 100644 --- a/scripts/migrate_paid_users.go +++ b/scripts/migrate_paid_users.go @@ -253,7 +253,7 @@ func main() { // 连接源库 fmt.Println("=== 付费用户数据迁移 ===") fmt.Println() - fmt.Print("[1/9] 连接源数据库... ") + fmt.Print("[1/10] 连接源数据库... ") srcDB, err := gorm.Open(mysql.Open(*srcDSN), gormCfg) if err != nil { log.Fatalf("源库连接失败: %v", err) @@ -261,7 +261,7 @@ func main() { fmt.Println("OK") // 连接目标库 - fmt.Print("[2/9] 连接目标数据库... ") + fmt.Print("[2/10] 连接目标数据库... ") dstDB, err := gorm.Open(mysql.Open(*dstDSN), gormCfg) if err != nil { log.Fatalf("目标库连接失败: %v", err) @@ -269,7 +269,7 @@ func main() { fmt.Println("OK") // ── Step 3: 查询付费用户 ID ── - fmt.Print("[3/9] 查询付费用户... ") + fmt.Print("[3/10] 查询付费用户... ") var paidIDs []int64 err = srcDB.Raw(` SELECT DISTINCT t.uid FROM ( @@ -291,7 +291,7 @@ func main() { } // ── Step 4: 读取源库数据 ── - fmt.Print("[4/9] 读取源库数据... ") + fmt.Print("[4/10] 读取源库数据... ") var ( users []User auths []AuthMethod @@ -374,6 +374,186 @@ func main() { 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 @@ -381,7 +561,7 @@ func main() { // ── Step 5: 清空目标库(可选) ── if *clean { - fmt.Print("[5/9] 清空目标库... ") + fmt.Print("[5/10] 清空目标库... ") dstDB.Exec("SET FOREIGN_KEY_CHECKS = 0") for _, tbl := range []string{ "user", "user_auth_methods", "user_device", @@ -393,11 +573,11 @@ func main() { dstDB.Exec("SET FOREIGN_KEY_CHECKS = 1") fmt.Println("OK") } else { - fmt.Println("[5/9] 跳过清空(未指定 -clean)") + fmt.Println("[5/10] 跳过清空(未指定 -clean)") } // ── Step 6: 写入全量配置表 ── - fmt.Print("[6/9] 写入全量配置表... ") + fmt.Print("[6/10] 写入全量配置表... ") if len(subPlans) > 0 { dstDB.Exec("DELETE FROM subscribe") // 先清再插 for _, s := range subPlans { @@ -413,7 +593,7 @@ func main() { fmt.Println("OK") // ── Step 7: 写入付费用户数据(事务) ── - fmt.Print("[7/9] 写入付费用户数据... ") + fmt.Print("[7/10] 写入付费用户数据... ") err = dstDB.Transaction(func(tx *gorm.DB) error { tx.Exec("SET FOREIGN_KEY_CHECKS = 0") @@ -445,7 +625,7 @@ func main() { fmt.Println("OK") // ── Step 8: 创建家庭组 ── - fmt.Print("[8/9] 创建家庭组... ") + fmt.Print("[8/10] 创建家庭组... ") now := time.Now() familyCount := 0 @@ -485,7 +665,7 @@ func main() { fmt.Printf("%d 个家庭组\n", familyCount) // ── Step 9: 多设备拆分 ── - fmt.Print("[9/9] 多设备拆分... ") + fmt.Print("[9/10] 多设备拆分... ") splitCount := 0 err = dstDB.Transaction(func(tx *gorm.DB) error { @@ -553,6 +733,35 @@ func main() { } 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("=== 迁移完成 ===")