This commit is contained in:
shanshanzhong 2026-01-27 10:42:04 -08:00
parent 48c92ea374
commit d1d95618ad
10 changed files with 533 additions and 57 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,8 +7,9 @@ const (
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
{{if eq .Type 1}}注册验证码 / Registration Verification Code{{else}}重置密码验证码 / Password
Reset Verification Code{{end}}
{{if eq .Type 1}}注册验证码
{{else if eq .Type 4}}注销账号验证
{{else}}验证码{{end}}
</title>
<style>
body {
@ -83,27 +84,23 @@ const (
<p class="site-name">{{.SiteName}}</p>
</div>
<div class="content">
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
<p class="greeting">Hi, 尊敬的用户</p>
<p>
{{if eq .Type 1}} 感谢您注册您的验证码是请于<span class="highlight">{{.Expire}}</span
>分钟内使用
<br />
Thank you for registering! Your verification code is (please use it within
<span class="highlight">{{.Expire}}</span> minutes): {{else}}
您正在重置密码您的验证码是请于<span class="highlight">{{.Expire}}</span>分钟内使用
<br />
You are resetting your password. Your verification code is (please use it within
<span class="highlight">{{.Expire}}</span> minutes): {{end}}
{{else if eq .Type 4}} 您正在申请注销账号您的验证码是请于<span class="highlight">{{.Expire}}</span>分钟内使用
{{else}}
您的验证码是请于<span class="highlight">{{.Expire}}</span>分钟内使用
{{end}}
</p>
<div class="code-container">
<span class="code">{{.Code}}</span>
</div>
<p>
如果您未请求此验证码请忽略此邮件<br />If you did not request this code, please ignore
this email.
如果您未请求此验证码请忽略此邮件
</p>
</div>
<div class="footer">此为系统邮件请勿回复 / This is a system email, please do not reply</div>
<div class="footer">此为系统邮件请勿回复</div>
</div>
</body>
</html>
@ -114,7 +111,7 @@ const (
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>系统维护通知 / System Maintenance Notice</title>
<title>系统维护通知</title>
<style>
body {
color: black;
@ -174,25 +171,17 @@ const (
<p class="site-name">{{.SiteName}}</p>
</div>
<div class="content">
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
<p class="greeting">Hi, 尊敬的用户</p>
<p>
我们计划在<span class="highlight">{{.MaintenanceDate}}</span
>进行系统维护预计维护时间为<span class="highlight">{{.MaintenanceTime}}</span
>在此期间您可能会遇到服务中断或无法访问的情况
<br />
We will be performing system maintenance on
<span class="highlight">{{.MaintenanceDate}}</span>, and the expected maintenance period
is <span class="highlight">{{.MaintenanceTime}}</span>. During this time, you may
experience service interruptions or unavailability.
</p>
<p>
维护完成后系统将自动恢复如果您有任何问题请随时联系我们的支持团队
<br />
The system will resume automatically once the maintenance is completed. If you have any
questions, please feel free to contact our support team.
</p>
</div>
<div class="footer">此为系统邮件请勿回复 / This is a system email, please do not reply</div>
<div class="footer">此为系统邮件请勿回复</div>
</div>
</body>
</html>
@ -202,7 +191,7 @@ const (
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>服务到期通知 / Service Expiration Notice</title>
<title>服务到期通知</title>
<style>
body {
color: black;
@ -262,22 +251,16 @@ const (
<p class="site-name">{{.SiteName}}</p>
</div>
<div class="content">
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
<p class="greeting">Hi, 尊敬的用户</p>
<p>
您的服务即将在<span class="highlight">{{.ExpireDate}}</span
>到期请及时续费以保证服务不间断
<br />
Your service is set to expire on <span class="highlight">{{.ExpireDate}}</span>. Please
renew your subscription to avoid service interruptions.
</p>
<p>
如需帮助请联系客服团队感谢您的支持
<br />
If you need assistance, please contact our support team. Thank you for your continued
support!
</p>
</div>
<div class="footer">此为系统邮件请勿回复 / This is a system email, please do not reply</div>
<div class="footer">此为系统邮件请勿回复</div>
</div>
</body>
</html>
@ -287,7 +270,7 @@ const (
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>流量用尽通知 / Traffic Exhausted Notice</title>
<title>流量用尽通知</title>
<style>
.container {
border-radius: 5px;
@ -342,21 +325,15 @@ const (
<p class="site-name">{{.SiteName}}</p>
</div>
<div class="content">
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
<p class="greeting">Hi, 尊敬的用户</p>
<p>
您的流量已经用尽请及时购买流量以继续使用我们的服务
<br />
Your traffic has been exhausted. Please purchase additional traffic to continue using our
service.
</p>
<p>
如需帮助请联系客服团队感谢您的支持
<br />
If you need assistance, please contact our support team. Thank you for your continued
support!
</p>
</div>
<div class="footer">此为系统邮件请勿回复 / This is a system email, please do not reply</div>
<div class="footer">此为系统邮件请勿回复</div>
</div>
</body>
</html>`

View File

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"strings"
"text/template"
"time"
@ -48,10 +49,33 @@ func (l *SendEmailLogic) ProcessTask(ctx context.Context, task *asynq.Task) erro
var content string
switch payload.Type {
case types.EmailTypeVerify:
tpl, _ := template.New("verify").Parse(l.svcCtx.Config.Email.VerifyEmailTemplate)
var result bytes.Buffer
tplStr := l.svcCtx.Config.Email.VerifyEmailTemplate
payload.Content["Type"] = uint8(payload.Content["Type"].(float64))
// Use int for better template compatibility
if t, ok := payload.Content["Type"].(float64); ok {
payload.Content["Type"] = int(t)
} else if t, ok := payload.Content["Type"].(int); ok {
payload.Content["Type"] = t
}
typeVal, _ := payload.Content["Type"].(int)
// Smart Fallback: If template is empty OR (Type is 4 but template doesn't support it), use default
// We check for "Type 4" or "Type eq 4" string in the template as a heuristic
needDefault := tplStr == ""
if !needDefault && typeVal == 4 &&
!strings.Contains(tplStr, "Type 4") &&
!strings.Contains(tplStr, "Type eq 4") {
logger.WithContext(ctx).Infow("[SendEmailLogic] Configured template might not support DeleteAccount (Type 4), forcing default template")
needDefault = true
}
if needDefault {
tplStr = email.DefaultEmailVerifyTemplate
}
tpl, _ := template.New("verify").Parse(tplStr)
var result bytes.Buffer
err = tpl.Execute(&result, payload.Content)
if err != nil {
@ -63,7 +87,11 @@ func (l *SendEmailLogic) ProcessTask(ctx context.Context, task *asynq.Task) erro
}
content = result.String()
case types.EmailTypeMaintenance:
tpl, _ := template.New("maintenance").Parse(l.svcCtx.Config.Email.MaintenanceEmailTemplate)
tplStr := l.svcCtx.Config.Email.MaintenanceEmailTemplate
if tplStr == "" {
tplStr = email.DefaultMaintenanceEmailTemplate
}
tpl, _ := template.New("maintenance").Parse(tplStr)
var result bytes.Buffer
err = tpl.Execute(&result, payload.Content)
if err != nil {
@ -76,7 +104,11 @@ func (l *SendEmailLogic) ProcessTask(ctx context.Context, task *asynq.Task) erro
}
content = result.String()
case types.EmailTypeExpiration:
tpl, _ := template.New("expiration").Parse(l.svcCtx.Config.Email.ExpirationEmailTemplate)
tplStr := l.svcCtx.Config.Email.ExpirationEmailTemplate
if tplStr == "" {
tplStr = email.DefaultExpirationEmailTemplate
}
tpl, _ := template.New("expiration").Parse(tplStr)
var result bytes.Buffer
err = tpl.Execute(&result, payload.Content)
if err != nil {
@ -89,7 +121,11 @@ func (l *SendEmailLogic) ProcessTask(ctx context.Context, task *asynq.Task) erro
}
content = result.String()
case types.EmailTypeTrafficExceed:
tpl, _ := template.New("traffic_exceed").Parse(l.svcCtx.Config.Email.TrafficExceedEmailTemplate)
tplStr := l.svcCtx.Config.Email.TrafficExceedEmailTemplate
if tplStr == "" {
tplStr = email.DefaultTrafficExceedEmailTemplate
}
tpl, _ := template.New("traffic_exceed").Parse(tplStr)
var result bytes.Buffer
err = tpl.Execute(&result, payload.Content)
if err != nil {