diff --git a/internal/config/config.go b/internal/config/config.go index 9f65611..813f603 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -101,11 +101,12 @@ type EmailConfig struct { EnableNotify bool `yaml:"enable_notify"` EnableDomainSuffix bool `yaml:"enable_domain_suffix"` DomainSuffixList string `yaml:"domain_suffix_list"` - VerifyEmailTemplate string `yaml:"verify_email_template"` - VerifyEmailTemplates map[string]string `yaml:"verify_email_templates"` - ExpirationEmailTemplate string `yaml:"expiration_email_template"` - MaintenanceEmailTemplate string `yaml:"maintenance_email_template"` - TrafficExceedEmailTemplate string `yaml:"traffic_exceed_email_template"` + VerifyEmailTemplate string `yaml:"verify_email_template"` + VerifyEmailTemplates map[string]string `yaml:"verify_email_templates"` + ExpirationEmailTemplate string `yaml:"expiration_email_template"` + MaintenanceEmailTemplate string `yaml:"maintenance_email_template"` + TrafficExceedEmailTemplate string `yaml:"traffic_exceed_email_template"` + DeleteAccountEmailTemplate string `yaml:"delete_account_email_template"` } type MobileConfig struct { diff --git a/internal/logic/admin/user/getFamilyDetailLogic.go b/internal/logic/admin/user/getFamilyDetailLogic.go index b58e112..d4db6d4 100644 --- a/internal/logic/admin/user/getFamilyDetailLogic.go +++ b/internal/logic/admin/user/getFamilyDetailLogic.go @@ -82,7 +82,7 @@ func (l *GetFamilyDetailLogic) GetFamilyDetail(req *types.GetFamilyDetailRequest memberItem := types.FamilyMemberItem{ UserId: member.UserId, Identifier: identifier, - DeviceNo: deviceNoMap[member.UserId], + DeviceID: deviceNoMap[member.UserId], Role: member.Role, RoleName: mapFamilyRoleName(member.Role), Status: member.Status, diff --git a/internal/logic/admin/user/getUserDetailLogic.go b/internal/logic/admin/user/getUserDetailLogic.go index 61d8b1e..e3b186c 100644 --- a/internal/logic/admin/user/getUserDetailLogic.go +++ b/internal/logic/admin/user/getUserDetailLogic.go @@ -37,7 +37,7 @@ func (l *GetUserDetailLogic) GetUserDetail(req *types.GetDetailRequest) (*types. tool.DeepCopy(&resp, userInfo) for i, d := range userInfo.UserDevices { if i < len(resp.UserDevices) { - resp.UserDevices[i].DeviceNo = tool.DeviceIdToHash(d.Id) + resp.UserDevices[i].DeviceID = tool.DeviceIdToHash(d.Id) } } if referCode := strings.TrimSpace(resp.ReferCode); referCode != "" { diff --git a/internal/logic/admin/user/getUserListLogic.go b/internal/logic/admin/user/getUserListLogic.go index d484a5d..ea1efd2 100644 --- a/internal/logic/admin/user/getUserListLogic.go +++ b/internal/logic/admin/user/getUserListLogic.go @@ -163,10 +163,10 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge } u.AuthMethods = authMethods - // 填充 DeviceNo + // 填充 DeviceID for i, d := range item.UserDevices { if i < len(u.UserDevices) { - u.UserDevices[i].DeviceNo = tool.DeviceIdToHash(d.Id) + u.UserDevices[i].DeviceID = tool.DeviceIdToHash(d.Id) } } diff --git a/internal/logic/admin/user/getUserSubscribeDevicesLogic.go b/internal/logic/admin/user/getUserSubscribeDevicesLogic.go index 007d61a..679f5d2 100644 --- a/internal/logic/admin/user/getUserSubscribeDevicesLogic.go +++ b/internal/logic/admin/user/getUserSubscribeDevicesLogic.go @@ -36,7 +36,7 @@ func (l *GetUserSubscribeDevicesLogic) GetUserSubscribeDevices(req *types.GetUse tool.DeepCopy(&userRespList, list) for i, d := range list { if i < len(userRespList) { - userRespList[i].DeviceNo = tool.DeviceIdToHash(d.Id) + userRespList[i].DeviceID = tool.DeviceIdToHash(d.Id) } } return &types.GetUserSubscribeDevicesResponse{ diff --git a/internal/logic/common/sendEmailCodeLogic.go b/internal/logic/common/sendEmailCodeLogic.go index 0170b99..06adfe9 100644 --- a/internal/logic/common/sendEmailCodeLogic.go +++ b/internal/logic/common/sendEmailCodeLogic.go @@ -89,7 +89,11 @@ func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *ty // Generate verification code code := random.Key(6, 0) scene := constant.ParseVerifyType(req.Type).String() - taskPayload.Type = queue.EmailTypeVerify + if scene == constant.DeleteAccount.String() { + taskPayload.Type = queue.EmailTypeDeleteAccount + } else { + taskPayload.Type = queue.EmailTypeVerify + } taskPayload.Scene = scene taskPayload.Email = req.Email taskPayload.Subject = "Verification code" diff --git a/internal/logic/public/user/getDeviceListLogic.go b/internal/logic/public/user/getDeviceListLogic.go index b1ea792..a1da1fb 100644 --- a/internal/logic/public/user/getDeviceListLogic.go +++ b/internal/logic/public/user/getDeviceListLogic.go @@ -38,7 +38,7 @@ func (l *GetDeviceListLogic) GetDeviceList() (resp *types.GetDeviceListResponse, tool.DeepCopy(&userRespList, list) for i, d := range list { if i < len(userRespList) { - userRespList[i].DeviceNo = tool.DeviceIdToHash(d.Id) + userRespList[i].DeviceID = tool.DeviceIdToHash(d.Id) } } resp = &types.GetDeviceListResponse{ diff --git a/internal/logic/public/user/queryUserInfoLogic.go b/internal/logic/public/user/queryUserInfoLogic.go index 7ddf548..21afb39 100644 --- a/internal/logic/public/user/queryUserInfoLogic.go +++ b/internal/logic/public/user/queryUserInfoLogic.go @@ -47,7 +47,7 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) { tool.DeepCopy(resp, u) for i, d := range u.UserDevices { if i < len(resp.UserDevices) { - resp.UserDevices[i].DeviceNo = tool.DeviceIdToHash(d.Id) + resp.UserDevices[i].DeviceID = tool.DeviceIdToHash(d.Id) } } // refer_code 为空时自动生成 diff --git a/internal/model/auth/auth.go b/internal/model/auth/auth.go index be40d92..6e70c95 100644 --- a/internal/model/auth/auth.go +++ b/internal/model/auth/auth.go @@ -113,17 +113,18 @@ func (l *TelegramAuthConfig) Unmarshal(data string) error { } type EmailAuthConfig struct { - Platform string `json:"platform"` - PlatformConfig interface{} `json:"platform_config"` - EnableVerify bool `json:"enable_verify"` - EnableNotify bool `json:"enable_notify"` - EnableDomainSuffix bool `json:"enable_domain_suffix"` - DomainSuffixList string `json:"domain_suffix_list"` - VerifyEmailTemplate string `json:"verify_email_template"` - VerifyEmailTemplates map[string]string `json:"verify_email_templates"` - ExpirationEmailTemplate string `json:"expiration_email_template"` - MaintenanceEmailTemplate string `json:"maintenance_email_template"` - TrafficExceedEmailTemplate string `json:"traffic_exceed_email_template"` + Platform string `json:"platform"` + PlatformConfig interface{} `json:"platform_config"` + EnableVerify bool `json:"enable_verify"` + EnableNotify bool `json:"enable_notify"` + EnableDomainSuffix bool `json:"enable_domain_suffix"` + DomainSuffixList string `json:"domain_suffix_list"` + VerifyEmailTemplate string `json:"verify_email_template"` + VerifyEmailTemplates map[string]string `json:"verify_email_templates"` + ExpirationEmailTemplate string `json:"expiration_email_template"` + MaintenanceEmailTemplate string `json:"maintenance_email_template"` + TrafficExceedEmailTemplate string `json:"traffic_exceed_email_template"` + DeleteAccountEmailTemplate string `json:"delete_account_email_template"` } func (l *EmailAuthConfig) Marshal() string { @@ -136,6 +137,9 @@ func (l *EmailAuthConfig) Marshal() string { if l.TrafficExceedEmailTemplate == "" { l.TrafficExceedEmailTemplate = email.DefaultTrafficExceedEmailTemplate } + if l.DeleteAccountEmailTemplate == "" { + l.DeleteAccountEmailTemplate = email.DefaultDeleteAccountEmailTemplate + } if l.VerifyEmailTemplate == "" { l.VerifyEmailTemplate = email.DefaultEmailVerifyTemplate } @@ -145,17 +149,18 @@ func (l *EmailAuthConfig) Marshal() string { bytes, err := json.Marshal(l) if err != nil { config := &EmailAuthConfig{ - Platform: "smtp", - PlatformConfig: new(SMTPConfig), - EnableVerify: true, - EnableNotify: true, - EnableDomainSuffix: false, - DomainSuffixList: "", - VerifyEmailTemplate: email.DefaultEmailVerifyTemplate, - VerifyEmailTemplates: map[string]string{}, - ExpirationEmailTemplate: email.DefaultExpirationEmailTemplate, - MaintenanceEmailTemplate: email.DefaultMaintenanceEmailTemplate, - TrafficExceedEmailTemplate: email.DefaultTrafficExceedEmailTemplate, + Platform: "smtp", + PlatformConfig: new(SMTPConfig), + EnableVerify: true, + EnableNotify: true, + EnableDomainSuffix: false, + DomainSuffixList: "", + VerifyEmailTemplate: email.DefaultEmailVerifyTemplate, + VerifyEmailTemplates: map[string]string{}, + ExpirationEmailTemplate: email.DefaultExpirationEmailTemplate, + MaintenanceEmailTemplate: email.DefaultMaintenanceEmailTemplate, + TrafficExceedEmailTemplate: email.DefaultTrafficExceedEmailTemplate, + DeleteAccountEmailTemplate: email.DefaultDeleteAccountEmailTemplate, } bytes, _ = json.Marshal(config) @@ -189,6 +194,9 @@ func (l *EmailAuthConfig) Unmarshal(data string) { if l.VerifyEmailTemplates == nil { l.VerifyEmailTemplates = map[string]string{} } + if l.DeleteAccountEmailTemplate == "" { + l.DeleteAccountEmailTemplate = email.DefaultDeleteAccountEmailTemplate + } } // SMTPConfig Email SMTP configuration diff --git a/internal/types/types.go b/internal/types/types.go index aa88d6e..8fe6a96 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -682,7 +682,7 @@ type FamilyDetail struct { type FamilyMemberItem struct { UserId int64 `json:"user_id"` Identifier string `json:"identifier"` - DeviceNo string `json:"device_no"` + DeviceID string `json:"device_id"` Role uint8 `json:"role"` RoleName string `json:"role_name"` Status uint8 `json:"status"` @@ -2957,7 +2957,7 @@ type UserDevice struct { Id int64 `json:"id"` Ip string `json:"ip"` Identifier string `json:"identifier"` - DeviceNo string `json:"device_no"` + DeviceID string `json:"device_id"` UserAgent string `json:"user_agent"` Online bool `json:"online"` Enabled bool `json:"enabled"` diff --git a/pkg/email/template.go b/pkg/email/template.go index 00121a0..7026e87 100644 --- a/pkg/email/template.go +++ b/pkg/email/template.go @@ -27,12 +27,12 @@ const (

- 注销验证 + 验证码

亲爱的用户, - 我们收到了你在{{.SiteName}}的注销请求 + 你正在进行{{.SiteName}}账户相关操作 请在系统提示时输入以下验证码:

@@ -300,4 +300,42 @@ const ( ` + + DefaultDeleteAccountEmailTemplate = ` + + + + + + + +
+ +
账户注销确认
+
+

您好,

+

我们收到了您注销 {{.SiteName}} 账户的请求。请在系统提示时输入以下验证码以完成注销:

+
{{.Code}}
+

验证码将在 {{.Expire}} 分钟后过期。

+
+

⚠️ 重要提示:账户注销后,您的所有数据(包括订阅、套餐、账户信息)将被永久删除且不可恢复。请确认您已充分了解此操作的后果。

+
+

如果这不是您本人的操作,请立即忽略本邮件,您的账户将保持安全。

+

谢谢,
{{.SiteName}} 团队

+
+ +
+ +` ) diff --git a/queue/logic/email/sendEmailLogic.go b/queue/logic/email/sendEmailLogic.go index 6779942..4ece514 100644 --- a/queue/logic/email/sendEmailLogic.go +++ b/queue/logic/email/sendEmailLogic.go @@ -60,14 +60,6 @@ func (l *SendEmailLogic) ProcessTask(ctx context.Context, task *asynq.Task) erro scene := resolveVerifyScene(payload.Scene, typeVal) tplStr := selectVerifyTemplate(l.svcCtx.Config.Email.VerifyEmailTemplates, l.svcCtx.Config.Email.VerifyEmailTemplate, scene) - if tplStr == l.svcCtx.Config.Email.VerifyEmailTemplate && - scene == constant.DeleteAccount.String() && - !strings.Contains(tplStr, "Type 4") && - !strings.Contains(tplStr, "Type eq 4") { - logger.WithContext(ctx).Infow("[SendEmailLogic] configured legacy verify template may not support DeleteAccount, fallback to default template") - tplStr = email.DefaultEmailVerifyTemplate - } - tpl, _ := template.New("verify").Parse(tplStr) var result bytes.Buffer @@ -80,6 +72,23 @@ func (l *SendEmailLogic) ProcessTask(ctx context.Context, task *asynq.Task) erro return nil } content = result.String() + case types.EmailTypeDeleteAccount: + tplStr := l.svcCtx.Config.Email.DeleteAccountEmailTemplate + if tplStr == "" { + tplStr = email.DefaultDeleteAccountEmailTemplate + } + tpl, _ := template.New("delete_account").Parse(tplStr) + var result bytes.Buffer + err = tpl.Execute(&result, payload.Content) + if err != nil { + logger.WithContext(ctx).Error("[SendEmailLogic] Execute template failed", + logger.Field("error", err.Error()), + logger.Field("template", l.svcCtx.Config.Email.DeleteAccountEmailTemplate), + logger.Field("data", payload.Content), + ) + return nil + } + content = result.String() case types.EmailTypeMaintenance: tplStr := l.svcCtx.Config.Email.MaintenanceEmailTemplate if tplStr == "" { diff --git a/queue/types/email.go b/queue/types/email.go index abeebca..8de146d 100644 --- a/queue/types/email.go +++ b/queue/types/email.go @@ -10,6 +10,7 @@ const ( EmailTypeMaintenance = "maintenance" EmailTypeExpiration = "expiration" EmailTypeTrafficExceed = "traffic_exceed" + EmailTypeDeleteAccount = "delete_account" EmailTypeCustom = "custom" ) diff --git a/scripts/migrate_paid_users.go b/scripts/migrate_paid_users.go index f5e1066..ecc62aa 100644 --- a/scripts/migrate_paid_users.go +++ b/scripts/migrate_paid_users.go @@ -313,6 +313,51 @@ func main() { 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(" 数据统计:") @@ -631,6 +676,11 @@ func main() { err = dstDB.Transaction(func(tx *gorm.DB) error { for _, uid := range paidIDs { + // 只为多设备用户创建家庭组 + if len(deviceByUser[uid]) <= 1 { + continue + } + family := UserFamily{ OwnerUserId: uid, MaxMembers: defaultFamilyMaxSize, @@ -703,10 +753,40 @@ func main() { } } - // 4. 查找原用户的家庭组 + // 4. 查找原用户的家庭组(如果不存在则创建,虽然理论上 Step 8 已经为多设备用户创建了) 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) + 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. 加入家庭组 diff --git a/说明文档.md b/说明文档.md index ca74ef5..47e05af 100644 --- a/说明文档.md +++ b/说明文档.md @@ -20,5 +20,8 @@ certbot certonly --manual --preferred-challenges dns -d airoport.win -d "*.airop - gunzip -c /Users/Apple/Downloads/db_backups_20260311_175705/mysql/mysql_dump_20260311_175556.sql.gz \ - | docker exec -i ppanel-db mysql -uroot -prootpassword \ No newline at end of file + gunzip -c /Users/Apple/Downloads/db_backups_20260315_000003/mysql/mysql_dump_20260311_173933.sql.gz \ + | docker exec -i ppanel-db mysql -uroot -prootpassword + + +go run scripts/migrate_paid_users.go -src 'root:rootpassword@tcp(127.0.0.1:3306)/ppanel?charset=utf8mb4&parseTime=True&loc=Local' -dst 'root:jpcV41ppanel@tcp(154.12.35.103:3306)/ppanel?charset=utf8mb4&parseTime=True&loc=Local' -clean \ No newline at end of file