From d1d95618adf8fc6202cbf6c1162b43a30134b74f Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Tue, 27 Jan 2026 10:42:04 -0800 Subject: [PATCH] =?UTF-8?q?=E9=82=AE=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apis/admin/user.api | 2 +- .../public/user/deleteAccountHandler.go | 2 +- internal/logic/admin/user/getUserListLogic.go | 6 +- .../user/bindEmailWithVerificationLogic.go | 32 ++ .../logic/public/user/deleteAccountLogic.go | 125 +++++++ .../public/user/deleteAccountLogic_test.go | 310 ++++++++++++++++++ internal/model/user/user.go | 2 +- internal/types/types.go | 2 +- pkg/email/template.go | 61 ++-- queue/logic/email/sendEmailLogic.go | 48 ++- 10 files changed, 533 insertions(+), 57 deletions(-) create mode 100644 internal/logic/public/user/deleteAccountLogic_test.go diff --git a/apis/admin/user.api b/apis/admin/user.api index 8b7a5ba..e408e04 100644 --- a/apis/admin/user.api +++ b/apis/admin/user.api @@ -43,7 +43,7 @@ type ( GiftAmount int64 `json:"gift_amount"` Telegram int64 `json:"telegram"` ReferCode string `json:"refer_code"` - RefererId int64 `json:"referer_id"` + RefererId *int64 `json:"referer_id"` Enable bool `json:"enable"` IsAdmin bool `json:"is_admin"` MemberStatus string `json:"member_status"` diff --git a/internal/handler/public/user/deleteAccountHandler.go b/internal/handler/public/user/deleteAccountHandler.go index a9ffeb8..6a23e63 100644 --- a/internal/handler/public/user/deleteAccountHandler.go +++ b/internal/handler/public/user/deleteAccountHandler.go @@ -42,7 +42,7 @@ func DeleteAccountHandler(serverCtx *svc.ServiceContext) gin.HandlerFunc { } l := user.NewDeleteAccountLogic(c.Request.Context(), serverCtx) - resp, err := l.DeleteAccount() + resp, err := l.DeleteAccountAll() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/logic/admin/user/getUserListLogic.go b/internal/logic/admin/user/getUserListLogic.go index 6b81e98..3aa2d41 100644 --- a/internal/logic/admin/user/getUserListLogic.go +++ b/internal/logic/admin/user/getUserListLogic.go @@ -2,9 +2,6 @@ package user import ( "context" - "fmt" - "math" - "time" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" @@ -66,8 +63,7 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge // Set MemberStatus and update LastLoginTime from traffic if info, ok := activeSubs[item.Id]; ok { - days := math.Ceil(info.ExpireTime.Sub(time.Now()).Hours() / 24) - u.MemberStatus = fmt.Sprintf("%s*%d", info.MemberStatus, int(days)) + u.MemberStatus = info.MemberStatus if info.LastTrafficAt != nil { trafficTime := info.LastTrafficAt.Unix() diff --git a/internal/logic/public/user/bindEmailWithVerificationLogic.go b/internal/logic/public/user/bindEmailWithVerificationLogic.go index 292dfbf..c15072e 100644 --- a/internal/logic/public/user/bindEmailWithVerificationLogic.go +++ b/internal/logic/public/user/bindEmailWithVerificationLogic.go @@ -135,6 +135,38 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi l.Infow("邮箱已存在,将设备转移到现有邮箱用户", logger.Field("email", req.Email), logger.Field("email_user_id", emailUserId)) + + // 补全邀请人逻辑:如果邮箱账号没有邀请人,但设备账号有,则继承设备账号的邀请人 + emailUser, err := l.svcCtx.UserModel.FindOne(l.ctx, emailUserId) + if err == nil { + updates := make(map[string]interface{}) + // 1. 处理 RefererId (邀请人) + if emailUser.RefererId == 0 && u.RefererId != 0 { + updates["referer_id"] = u.RefererId + l.Infow("将设备账号邀请人转移给邮箱账号", + logger.Field("email_user_id", emailUserId), + logger.Field("referer_id", u.RefererId)) + } + // 2. 处理 ReferCode (如果邮箱账号意外没有邀请码,沿用设备的或生成新的) - 这是一个兜底,通常创建用户时已有 + if emailUser.ReferCode == "" { + if u.ReferCode != "" { + updates["refer_code"] = u.ReferCode + } else { + updates["refer_code"] = uuidx.UserInviteCode(emailUserId) + } + } + + if len(updates) > 0 { + if err := l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error { + return tx.Model(&user.User{}).Where("id = ?", emailUserId).Updates(updates).Error + }); err != nil { + l.Errorw("更新邮箱用户信息失败", logger.Field("error", err.Error())) + // 不阻断主流程 + } + } + } else { + l.Errorw("查询目标邮箱用户失败,跳过邀请人合并", logger.Field("error", err.Error())) + } // 创建前 需要 吧 原本的 user_devices 表中过的数据删除掉 防止出现两个记录 devices, _, err := l.svcCtx.UserModel.QueryDeviceList(l.ctx, u.Id) if err != nil { diff --git a/internal/logic/public/user/deleteAccountLogic.go b/internal/logic/public/user/deleteAccountLogic.go index 43835e9..8b1166f 100644 --- a/internal/logic/public/user/deleteAccountLogic.go +++ b/internal/logic/public/user/deleteAccountLogic.go @@ -131,6 +131,104 @@ func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse, return resp, nil } +// DeleteAccountAll 注销账号逻辑 (全部解绑默认创建账号) +func (l *DeleteAccountLogic) DeleteAccountAll() (resp *types.DeleteAccountResponse, err error) { + // 获取当前用户 + currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) + if !ok { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } + + // 获取当前调用设备 ID + currentDeviceId, _ := l.ctx.Value(constant.CtxKeyDeviceID).(int64) + + resp = &types.DeleteAccountResponse{} + var newUserId int64 + + // 开始数据库事务 + err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error { + // 1. 预先查找该用户下的所有设备记录 (因为稍后要全删) + var userDevices []user.Device + if err := tx.Where("user_id = ?", currentUser.Id).Find(&userDevices).Error; err != nil { + l.Errorw("查询用户设备列表失败", logger.Field("user_id", currentUser.Id), logger.Field("error", err.Error())) + return err + } + + // 如果没有识别到调用设备 ID,记录日志但继续执行 (全量注销不应受限) + if currentDeviceId == 0 { + l.Infow("未识别到当前设备 ID,将执行全量注销并尝试迁移所有已知设备", logger.Field("user_id", currentUser.Id), logger.Field("found_devices", len(userDevices))) + } + + // 2. 无条件执行全量删除 (清理旧账号数据) + l.Infow("执行账号全量注销-清理旧数据", logger.Field("user_id", currentUser.Id), logger.Field("device_count", len(userDevices))) + + // 删除所有认证方式 + if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.AuthMethods{}).Error; err != nil { + return errors.Wrap(err, "删除认证方式失败") + } + + // 删除所有设备记录 + if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.Device{}).Error; err != nil { + return errors.Wrap(err, "删除设备记录失败") + } + + // 删除所有订阅 + if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.Subscribe{}).Error; err != nil { + return errors.Wrap(err, "删除订阅失败") + } + + // 删除用户主体 + if err := tx.Delete(&user.User{}, currentUser.Id).Error; err != nil { + return errors.Wrap(err, "删除用户失败") + } + + // 3. 循环为每个设备重新创建匿名用户 (分配账号,避免重新注册领试用) + for _, dev := range userDevices { + // 为该设备创建新用户 + newUser, err := l.registerUserAndDevice(tx, dev.Identifier, dev.Ip, dev.UserAgent) + if err != nil { + l.Errorw("为旧设备分配新账号失败", logger.Field("identifier", dev.Identifier), logger.Field("error", err.Error())) + return err + } + + // 如果是当前请求的设备,记录其新 UserID 返回给前端 + if dev.Id == currentDeviceId || dev.Identifier == l.getIdentifierByDeviceID(userDevices, currentDeviceId) { + newUserId = newUser.Id + } + l.Infow("旧设备已迁移至新匿名账号", logger.Field("old_user_id", currentUser.Id), logger.Field("new_user_id", newUser.Id), logger.Field("identifier", dev.Identifier)) + } + + // 如果循环结束还没找到当前设备的新ID (极端情况,比如currentDeviceId不在列表中),这里可能需要处理 + // 通常第一步查询 userDevices 应该包含 currentDeviceId,除非并发删除 + + return nil + }) + + if err != nil { + return nil, err + } + + // 最终清理所有 Session (踢掉所有设备) + l.clearAllSessions(currentUser.Id) + + resp.Success = true + resp.Message = "注销成功" + resp.UserId = newUserId + resp.Code = 200 + + return resp, nil +} + +// 辅助方法:通过 ID 查找 Identifier (以防 currentDeviceId 只是 ID) +func (l *DeleteAccountLogic) getIdentifierByDeviceID(devices []user.Device, id int64) string { + for _, d := range devices { + if d.Id == id { + return d.Identifier + } + } + return "" +} + // clearCurrentSession 清理当前请求的会话 func (l *DeleteAccountLogic) clearCurrentSession(userId int64) { if sessionId, ok := l.ctx.Value(constant.CtxKeySessionID).(string); ok && sessionId != "" { @@ -146,6 +244,33 @@ func (l *DeleteAccountLogic) clearCurrentSession(userId int64) { } } +// clearAllSessions 清理指定用户的所有会话 +func (l *DeleteAccountLogic) clearAllSessions(userId int64) { + sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId) + + // 获取所有 session id + sessions, err := l.svcCtx.Redis.ZRange(l.ctx, sessionsKey, 0, -1).Result() + if err != nil { + l.Errorw("获取用户会话列表失败", logger.Field("user_id", userId), logger.Field("error", err.Error())) + return + } + + // 删除每个 session 的详情 key + for _, sid := range sessions { + sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sid) + _ = l.svcCtx.Redis.Del(l.ctx, sessionKey).Err() + + // 同时尝试删除 detail key (如果存在) + detailKey := fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sid) + _ = l.svcCtx.Redis.Del(l.ctx, detailKey).Err() + } + + // 删除用户的 session 集合 key + _ = l.svcCtx.Redis.Del(l.ctx, sessionsKey).Err() + + l.Infow("[SessionMonitor] 注销账号-清除所有Session", logger.Field("user_id", userId), logger.Field("count", len(sessions))) +} + // generateReferCode 生成推荐码 func generateReferCode() string { bytes := make([]byte, 4) diff --git a/internal/logic/public/user/deleteAccountLogic_test.go b/internal/logic/public/user/deleteAccountLogic_test.go new file mode 100644 index 0000000..5c85a0b --- /dev/null +++ b/internal/logic/public/user/deleteAccountLogic_test.go @@ -0,0 +1,310 @@ +package user + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/constant" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func createTestSvcCtx(t *testing.T, testName string) (*svc.ServiceContext, *gorm.DB, *miniredis.Miniredis) { + // 1. Setup Miniredis + mr, err := miniredis.Run() + assert.NoError(t, err) + + rdb := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + + // 2. Setup GORM with SQLite (File based for reliability) + dbName := fmt.Sprintf("test_%s.db", testName) + db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) + assert.NoError(t, err) + + t.Cleanup(func() { + os.Remove(dbName) + }) + + // Migrate tables (Using Migrator to bypass index collision errors on global schema in SQLite) + _ = db.Migrator().CreateTable(&user.User{}) + _ = db.Migrator().CreateTable(&user.Device{}) + _ = db.Migrator().CreateTable(&user.AuthMethods{}) + _ = db.Migrator().CreateTable(&user.Subscribe{}) + + // 3. Create ServiceContext + c := config.Config{} + c.Invite.OnlyFirstPurchase = true + + svcCtx := &svc.ServiceContext{ + Redis: rdb, + DB: db, + Config: c, + UserModel: user.NewModel(db, rdb), + } + + return svcCtx, db, mr +} + +func TestDeleteAccount_Guest_SingleDevice(t *testing.T) { + svcCtx, db, mr := createTestSvcCtx(t, t.Name()) + defer mr.Close() + + // Setup: User, 1 Device, No Email + u := &user.User{ + Id: 1, + ReferCode: "ref1", + } + db.Create(u) + + device := &user.Device{ + Id: 10, + UserId: 1, + Identifier: "device1_id", + } + db.Create(device) + + auth := &user.AuthMethods{ + UserId: 1, + AuthType: "device", + AuthIdentifier: "device1_id", + } + db.Create(auth) + + // Context + ctx := context.Background() + ctx = context.WithValue(ctx, constant.CtxKeyUser, u) + ctx = context.WithValue(ctx, constant.CtxKeyDeviceID, int64(10)) + ctx = context.WithValue(ctx, constant.CtxKeySessionID, "session1") + + // Run Logic + l := NewDeleteAccountLogic(ctx, svcCtx) + resp, err := l.DeleteAccountAll() + if err != nil { + t.Fatalf("DeleteAccountAll failed: %v", err) + } + assert.True(t, resp.Success) + + // Assertions for Guest User (Should be DELETED) + // Because 1 auth (device) and 1 device count -> isMainAccount = false + + var userCount int64 + db.Model(&user.User{}).Where("refer_code = ?", "ref1").Count(&userCount) + assert.Equal(t, int64(0), userCount, "Old User (by refer code) should be deleted") + + // Old device record (ID 10) should be gone + var deviceCount int64 + db.Model(&user.Device{}).Where("id = ?", 10).Count(&deviceCount) + assert.Equal(t, int64(0), deviceCount, "Old device record should be deleted") + + // Check if new user created for the device + var newDeviceCount int64 + db.Model(&user.Device{}).Where("identifier = ?", "device1_id").Count(&newDeviceCount) + assert.Equal(t, int64(1), newDeviceCount, "New device record should be created") +} + +func TestDeleteAccount_User_WithEmail(t *testing.T) { + svcCtx, db, mr := createTestSvcCtx(t, t.Name()) + defer mr.Close() + + // Setup: User, 1 Device, 1 Email + u := &user.User{ + Id: 2, + } + db.Create(u) + + device := &user.Device{ + Id: 20, + UserId: 2, + Identifier: "device2_id", + } + db.Create(device) + + authDevice := &user.AuthMethods{ + UserId: 2, + AuthType: "device", + AuthIdentifier: "device2_id", + } + db.Create(authDevice) + + authEmail := &user.AuthMethods{ + UserId: 2, + AuthType: "email", + AuthIdentifier: "test@example.com", + } + db.Create(authEmail) + + // Context + ctx := context.Background() + ctx = context.WithValue(ctx, constant.CtxKeyUser, u) + ctx = context.WithValue(ctx, constant.CtxKeyDeviceID, int64(20)) + ctx = context.WithValue(ctx, constant.CtxKeySessionID, "session2") + + // Run Logic + l := NewDeleteAccountLogic(ctx, svcCtx) + resp, err := l.DeleteAccountAll() + if err != nil { + t.Fatalf("DeleteAccountAll failed: %v", err) + } + assert.True(t, resp.Success) + + // Assertions for Email User (Should BE deleted now) + var userCount int64 + db.Model(&user.User{}).Where("id = ?", 2).Count(&userCount) + assert.Equal(t, int64(0), userCount, "User should be deleted") + + var deviceCount int64 + db.Model(&user.Device{}).Where("id = ?", 20).Count(&deviceCount) + assert.Equal(t, int64(0), deviceCount, "Old device record should be deleted") + + var authDeviceCount int64 + db.Model(&user.AuthMethods{}).Where("user_id = ? AND auth_type = 'device'", 2).Count(&authDeviceCount) + assert.Equal(t, int64(0), authDeviceCount, "Device auth should be removed") + + var authEmailCount int64 + db.Model(&user.AuthMethods{}).Where("user_id = ? AND auth_type = 'email'", 2).Count(&authEmailCount) + assert.Equal(t, int64(0), authEmailCount, "Email auth should be removed") +} + +func TestDeleteAccount_User_MultiDevice(t *testing.T) { + svcCtx, db, mr := createTestSvcCtx(t, t.Name()) + defer mr.Close() + + // Setup: User, 2 Devices + u := &user.User{ + Id: 3, + } + db.Create(u) + + // Device 1 (Current) + device1 := &user.Device{ + Id: 31, + UserId: 3, + Identifier: "device3_1", + } + db.Create(device1) + auth1 := &user.AuthMethods{ + UserId: 3, + AuthType: "device", + AuthIdentifier: "device3_1", + } + db.Create(auth1) + + // Device 2 (Other) + device2 := &user.Device{ + Id: 32, + UserId: 3, + Identifier: "device3_2", + } + db.Create(device2) + auth2 := &user.AuthMethods{ + UserId: 3, + AuthType: "device", + AuthIdentifier: "device3_2", + } + db.Create(auth2) + + // Context + ctx := context.Background() + ctx = context.WithValue(ctx, constant.CtxKeyUser, u) + ctx = context.WithValue(ctx, constant.CtxKeyDeviceID, int64(31)) // Current = Device 1 + ctx = context.WithValue(ctx, constant.CtxKeySessionID, "session3") + + // Run Logic + l := NewDeleteAccountLogic(ctx, svcCtx) + resp, err := l.DeleteAccountAll() + if err != nil { + t.Fatalf("DeleteAccountAll failed: %v", err) + } + assert.True(t, resp.Success) + + // Assertions for Multi Device User (Should BE deleted now) + var userCount int64 + db.Model(&user.User{}).Where("id = ?", 3).Count(&userCount) + assert.Equal(t, int64(0), userCount, "Old User should be deleted") + + // Old device records should be deleted + var device1Count int64 + db.Model(&user.Device{}).Where("id = ?", 31).Count(&device1Count) + assert.Equal(t, int64(0), device1Count, "Old device 1 record should be deleted") + + var device2Count int64 + db.Model(&user.Device{}).Where("id = ?", 32).Count(&device2Count) + assert.Equal(t, int64(0), device2Count, "Old device 2 record should be deleted") + + // NEW Device records should be created + var newDevice1Count int64 + db.Model(&user.Device{}).Where("identifier = ?", "device3_1").Count(&newDevice1Count) + assert.Equal(t, int64(1), newDevice1Count, "New Device 1 should be created") + + var newDevice2Count int64 + db.Model(&user.Device{}).Where("identifier = ?", "device3_2").Count(&newDevice2Count) + assert.Equal(t, int64(1), newDevice2Count, "New Device 2 should be created") + + // Verify they are anonymous (different users, assuming registerUserAndDevice creates new user each time) + var newDev1 user.Device + db.Where("identifier = ?", "device3_1").First(&newDev1) + assert.NotEqual(t, int64(3), newDev1.UserId, "New Device 1 should have new UserID") + + var newDev2 user.Device + db.Where("identifier = ?", "device3_2").First(&newDev2) + assert.NotEqual(t, int64(3), newDev2.UserId, "New Device 2 should have new UserID") + + assert.NotEqual(t, newDev1.UserId, newDev2.UserId, "Devices should have independent user accounts") +} + +func TestDeleteAccount_MissingDeviceID(t *testing.T) { + svcCtx, db, mr := createTestSvcCtx(t, t.Name()) + defer mr.Close() + + // Setup: User, 1 Device, but context missing DeviceID + u := &user.User{ + Id: 4, + ReferCode: "ref4", + } + db.Create(u) + + device := &user.Device{ + Id: 40, + UserId: 4, + Identifier: "device4_id", + } + db.Create(device) + + // Context (Missing DeviceID) + ctx := context.Background() + ctx = context.WithValue(ctx, constant.CtxKeyUser, u) + ctx = context.WithValue(ctx, constant.CtxKeySessionID, "session4") + + // Run Logic + l := NewDeleteAccountLogic(ctx, svcCtx) + resp, err := l.DeleteAccountAll() + if err != nil { + t.Fatalf("DeleteAccountAll failed: %v", err) + } + assert.True(t, resp.Success) + + // Assertions: User should be deleted + var userCount int64 + db.Model(&user.User{}).Where("refer_code = ?", "ref4").Count(&userCount) + assert.Equal(t, int64(0), userCount, "User should be deleted even without device context") + + // Old device should be deleted + var deviceCount int64 + db.Model(&user.Device{}).Where("id = ?", 40).Count(&deviceCount) + assert.Equal(t, int64(0), deviceCount, "Old device record should be deleted") + + // New device should be created for the orphaned device + var newDeviceCount int64 + db.Model(&user.Device{}).Where("identifier = ?", "device4_id").Count(&newDeviceCount) + assert.Equal(t, int64(1), newDeviceCount, "Device should be reset to new account even if caller didn't specify deviceID") +} diff --git a/internal/model/user/user.go b/internal/model/user/user.go index 44302d9..dfb67d8 100644 --- a/internal/model/user/user.go +++ b/internal/model/user/user.go @@ -42,7 +42,7 @@ type Subscribe struct { User User `gorm:"foreignKey:UserId;references:Id"` OrderId int64 `gorm:"index:idx_order_id;not null;comment:Order ID"` SubscribeId int64 `gorm:"index:idx_subscribe_id;not null;comment:Subscription ID"` - StartTime time.Time `gorm:"default:CURRENT_TIMESTAMP(3);not null;comment:Subscription Start Time"` + StartTime time.Time `gorm:"default:CURRENT_TIMESTAMP;not null;comment:Subscription Start Time"` ExpireTime time.Time `gorm:"default:NULL;comment:Subscription Expire Time"` FinishedAt *time.Time `gorm:"default:NULL;comment:Finished Time"` Traffic int64 `gorm:"default:0;comment:Traffic"` diff --git a/internal/types/types.go b/internal/types/types.go index 28b704d..3f16fec 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -2566,7 +2566,7 @@ type UpdateUserBasiceInfoRequest struct { GiftAmount int64 `json:"gift_amount"` Telegram int64 `json:"telegram"` ReferCode string `json:"refer_code"` - RefererId int64 `json:"referer_id"` + RefererId *int64 `json:"referer_id"` Enable bool `json:"enable"` IsAdmin bool `json:"is_admin"` MemberStatus string `json:"member_status"` diff --git a/pkg/email/template.go b/pkg/email/template.go index 5d2bd5b..787bf39 100644 --- a/pkg/email/template.go +++ b/pkg/email/template.go @@ -7,8 +7,9 @@ const ( - {{if eq .Type 1}}注册验证码 / Registration Verification Code{{else}}重置密码验证码 / Password - Reset Verification Code{{end}} + {{if eq .Type 1}}注册验证码 + {{else if eq .Type 4}}注销账号验证 + {{else}}验证码{{end}}