注销
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m34s

This commit is contained in:
shanshanzhong 2026-03-17 07:12:42 -07:00
parent 3cd22d8538
commit dcdbabdb13
15 changed files with 195 additions and 51 deletions

View File

@ -106,6 +106,7 @@ type EmailConfig struct {
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 {

View File

@ -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,

View File

@ -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 != "" {

View File

@ -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)
}
}

View File

@ -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{

View File

@ -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()
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"

View File

@ -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{

View File

@ -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 为空时自动生成

View File

@ -124,6 +124,7 @@ type EmailAuthConfig struct {
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
}
@ -156,6 +160,7 @@ func (l *EmailAuthConfig) Marshal() 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

View File

@ -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"`

View File

@ -27,12 +27,12 @@ const (
<tr>
<td style="padding:0 43px 0 43px;">
<h1 style="font-weight:600; font-size:36px; line-height:1.4; color:#0F2C53; margin:0 0 24px 0;">
注销验证
验证
</h1>
<p style="font-weight:400; font-size:16px; line-height:1.4; color:#0F2C53; margin:0 0 24px 0; white-space:pre-line;">
亲爱的用户
我们收到了你在{{.SiteName}}的注销请求
进行{{.SiteName}}账户相关操作
请在系统提示时输入以下验证码
</p>
@ -300,4 +300,42 @@ const (
</div>
</body>
</html>`
DefaultDeleteAccountEmailTemplate = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body { margin: 0; padding: 0; background: #f4f4f4; font-family: 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; }
.container { max-width: 600px; margin: 0 auto; background: #fff; padding: 40px; }
.logo { text-align: right; margin-bottom: 40px; }
.title { font-size: 28px; font-weight: 600; color: #0F2C53; margin-bottom: 16px; }
.content { font-size: 16px; color: #0F2C53; line-height: 1.6; }
.warning-box { background: #FFF3CD; border-left: 4px solid #FF8C00; padding: 16px 20px; margin: 24px 0; border-radius: 4px; }
.warning-box p { margin: 0; font-size: 15px; color: #5C3D00; }
.footer { margin-top: 40px; font-size: 12px; color: #999; text-align: center; }
</style>
</head>
<body>
<div class="container">
<div class="logo">
{{if .SiteLogo}}<img src="{{.SiteLogo}}" alt="{{.SiteName}}" height="47" style="display:block;" />{{end}}
</div>
<div class="title">账户注销确认</div>
<div class="content">
<p>您好</p>
<p>我们收到了您注销 <strong>{{.SiteName}}</strong> 账户的请求请在系统提示时输入以下验证码以完成注销</p>
<div style="font-size: 48px; font-weight: 700; color: #0F2C53; letter-spacing: 4px; margin: 24px 0;">{{.Code}}</div>
<p>验证码将在 <strong>{{.Expire}} 分钟</strong>后过期</p>
<div class="warning-box">
<p> <strong>重要提示</strong>账户注销后您的所有数据包括订阅套餐账户信息将被<strong>永久删除且不可恢复</strong>请确认您已充分了解此操作的后果</p>
</div>
<p>如果这不是您本人的操作请立即忽略本邮件您的账户将保持安全</p>
<p>谢谢<br />{{.SiteName}} 团队</p>
</div>
<div class="footer">此为系统邮件请勿回复</div>
</div>
</body>
</html>`
)

View File

@ -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 == "" {

View File

@ -10,6 +10,7 @@ const (
EmailTypeMaintenance = "maintenance"
EmailTypeExpiration = "expiration"
EmailTypeTrafficExceed = "traffic_exceed"
EmailTypeDeleteAccount = "delete_account"
EmailTypeCustom = "custom"
)

View File

@ -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,11 +753,41 @@ func main() {
}
}
// 4. 查找原用户的家庭组
// 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{

View File

@ -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 \
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