feat: 新增多密码验证支持及架构文档
Build docker and publish / build (20.15.1) (push) Has been cancelled

refactor: 重构用户模型和密码验证逻辑
feat(epay): 添加支付类型支持
docs: 添加安装和配置指南文档
fix: 修复优惠券过期检查逻辑
perf: 优化设备解绑缓存清理流程
test: 添加密码验证测试用例
chore: 更新依赖版本
This commit is contained in:
2025-10-27 18:54:07 -07:00
parent fde3210a88
commit 00255a7118
65 changed files with 1523 additions and 504 deletions
+1 -1
View File
@@ -203,7 +203,7 @@ type InviteConfig struct {
ForcedInvite bool `yaml:"ForcedInvite" default:"false"`
ReferralPercentage int64 `yaml:"ReferralPercentage" default:"0"`
OnlyFirstPurchase bool `yaml:"OnlyFirstPurchase" default:"false"`
GiftDays int64 `yaml:"GiftDays" default:"3"`
GiftDays int64 `yaml:"GiftDays" default:"0"`
}
type Telegram struct {
@@ -8,10 +8,10 @@ import (
"github.com/perfect-panel/server/pkg/result"
)
// Bind Email With Password
func BindEmailWithPasswordHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
// Bind Email With Verification
func BindEmailWithVerificationHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.BindEmailWithPasswordRequest
var req types.BindEmailWithVerificationRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
@@ -19,8 +19,8 @@ func BindEmailWithPasswordHandler(svcCtx *svc.ServiceContext) func(c *gin.Contex
return
}
l := user.NewBindEmailWithPasswordLogic(c.Request.Context(), svcCtx)
resp, err := l.BindEmailWithPassword(&req)
l := user.NewBindEmailWithVerificationLogic(c.Request.Context(), svcCtx)
resp, err := l.BindEmailWithVerification(&req)
result.HttpResult(c, resp, err)
}
}
}
+2 -8
View File
@@ -780,12 +780,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Update Bind Email
publicUserGroupRouter.PUT("/bind_email", publicUser.UpdateBindEmailHandler(serverCtx))
// Bind Email With Password
publicUserGroupRouter.POST("/bind_email_with_password", publicUser.BindEmailWithPasswordHandler(serverCtx))
// Bind Invite Code
publicUserGroupRouter.POST("/bind_invite_code", publicUser.BindInviteCodeHandler(serverCtx))
// Update Bind Mobile
publicUserGroupRouter.PUT("/bind_mobile", publicUser.UpdateBindMobileHandler(serverCtx))
@@ -867,10 +861,10 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
serverGroupRouter.GET("/user", server.GetServerUserListHandler(serverCtx))
}
serverGroupRouterV2 := router.Group("/v2/server")
serverV2GroupRouter := router.Group("/v2/server")
{
// Get Server Protocol Config
serverGroupRouterV2.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx))
serverV2GroupRouter.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx))
}
}
@@ -36,6 +36,12 @@ func QueryServerProtocolConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Co
fmt.Printf("[QueryServerProtocolConfigHandler] - ShouldBindQuery request: %+v\n", req)
if svcCtx.Config.Node.NodeSecret != req.SecretKey {
c.String(http.StatusUnauthorized, "Unauthorized")
c.Abort()
return
}
l := server.NewQueryServerProtocolConfigLogic(c.Request.Context(), svcCtx)
resp, err := l.QueryServerProtocolConfig(&req)
result.HttpResult(c, resp, err)
@@ -40,6 +40,7 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserRequest) error {
pwd := tool.EncodePassWord(req.Password)
newUser := &user.User{
Password: pwd,
Algo: "default",
ReferralPercentage: req.ReferralPercentage,
OnlyFirstPurchase: &req.OnlyFirstPurchase,
ReferCode: req.ReferCode,
@@ -129,6 +129,7 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
return errors.Wrapf(xerr.NewErrCodeMsg(503, "Demo mode does not allow modification of the admin user password"), "UpdateUserBasicInfo failed: cannot update admin user password in demo mode")
}
userInfo.Password = tool.EncodePassWord(req.Password)
userInfo.Algo = "default"
}
err = l.svcCtx.UserModel.Update(l.ctx, userInfo)
+74 -17
View File
@@ -19,13 +19,14 @@ import (
"gorm.io/gorm"
)
// DeviceLoginLogic 设备登录逻辑结构体
type DeviceLoginLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Device Login
// NewDeviceLoginLogic 创建设备登录逻辑实例
func NewDeviceLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeviceLoginLogic {
return &DeviceLoginLogic{
Logger: logger.WithContext(ctx),
@@ -34,14 +35,17 @@ func NewDeviceLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Devic
}
}
// DeviceLogin 设备登录主要逻辑
func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *types.LoginResponse, err error) {
// 检查设备登录是否启用
if !l.svcCtx.Config.Device.Enable {
return nil, xerr.NewErrMsg("Device login is disabled")
}
loginStatus := false
var userInfo *user.User
// Record login status
// 延迟执行:记录登录状态日志
defer func() {
if userInfo != nil && userInfo.Id != 0 {
loginLog := log.Login{
@@ -67,14 +71,61 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
}
}()
// Check if device exists by identifier
// 根据设备标识符查找设备信息
deviceInfo, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Device not found, create new user and device
userInfo, err = l.registerUserAndDevice(req)
if err != nil {
return nil, err
// 设备未找到,但需要检查认证方法是否已存在
authMethod, authErr := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "device", req.Identifier)
if authErr != nil && !errors.Is(authErr, gorm.ErrRecordNotFound) {
l.Errorw("query auth method failed",
logger.Field("identifier", req.Identifier),
logger.Field("error", authErr.Error()),
)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query auth method failed: %v", authErr.Error())
}
if authMethod != nil {
// 认证方法存在但设备记录不存在,可能是数据不一致,获取用户信息并重新创建设备记录
userInfo, err = l.svcCtx.UserModel.FindOne(l.ctx, authMethod.UserId)
if err != nil {
l.Errorw("query user by auth method failed",
logger.Field("user_id", authMethod.UserId),
logger.Field("identifier", req.Identifier),
logger.Field("error", err.Error()),
)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user failed: %v", err.Error())
}
// 重新创建缺失的设备记录
deviceInfo := &user.Device{
Ip: req.IP,
UserId: userInfo.Id,
UserAgent: req.UserAgent,
Identifier: req.Identifier,
Enabled: true,
Online: false,
}
if err := l.svcCtx.UserModel.InsertDevice(l.ctx, deviceInfo); err != nil {
l.Errorw("failed to recreate device record",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", req.Identifier),
logger.Field("error", err.Error()),
)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "recreate device record failed: %v", err)
}
l.Infow("found existing auth method without device record, recreated device record",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", req.Identifier),
logger.Field("device_id", deviceInfo.Id),
)
} else {
// 设备和认证方法都不存在,创建新用户和设备
userInfo, err = l.registerUserAndDevice(req)
if err != nil {
return nil, err
}
}
} else {
l.Errorw("query device failed",
@@ -84,7 +135,7 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device failed: %v", err.Error())
}
} else {
// Device found, get user info
// 设备已存在,获取用户信息
userInfo, err = l.svcCtx.UserModel.FindOne(l.ctx, deviceInfo.UserId)
if err != nil {
l.Errorw("query user failed",
@@ -95,10 +146,10 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
}
}
// Generate session id
// 生成会话ID
sessionId := uuidx.NewUUID().String()
// Generate token
// 生成JWT令牌
token, err := jwt.NewJwtToken(
l.svcCtx.Config.JwtAuth.AccessSecret,
time.Now().Unix(),
@@ -115,7 +166,7 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
}
// Store session id in redis
// 将会话ID存储到Redis
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
l.Errorw("set session id error",
@@ -131,6 +182,7 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
}, nil
}
// registerUserAndDevice 注册新用户和设备
func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest) (*user.User, error) {
l.Infow("device not found, creating new user and device",
logger.Field("identifier", req.Identifier),
@@ -138,8 +190,9 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
)
var userInfo *user.User
// 使用数据库事务确保数据一致性
err := l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error {
// Create new user
// 创建新用户
userInfo = &user.User{
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
}
@@ -150,7 +203,7 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create user failed: %v", err)
}
// Update refer code
// 更新用户邀请码
userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id)
if err := db.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil {
l.Errorw("failed to update refer code",
@@ -160,7 +213,7 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update refer code failed: %v", err)
}
// Create device auth method
// 创建设备认证方式记录
authMethod := &user.AuthMethods{
UserId: userInfo.Id,
AuthType: "device",
@@ -176,7 +229,7 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create device auth method failed: %v", err)
}
// Insert device record
// 插入设备记录
deviceInfo := &user.Device{
Ip: req.IP,
UserId: userInfo.Id,
@@ -194,7 +247,7 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert device failed: %v", err)
}
// Activate trial if enabled
// 如果启用了试用,则激活试用订阅
if l.svcCtx.Config.Register.EnableTrial {
if err := l.activeTrial(userInfo.Id, db); err != nil {
return err
@@ -218,7 +271,7 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
logger.Field("refer_code", userInfo.ReferCode),
)
// Register log
// 记录注册日志
registerLog := log.Register{
AuthMethod: "device",
Identifier: req.Identifier,
@@ -244,7 +297,9 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
return userInfo, nil
}
// activeTrial 激活试用订阅
func (l *DeviceLoginLogic) activeTrial(userId int64, db *gorm.DB) error {
// 查找试用订阅模板
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, l.svcCtx.Config.Register.TrialSubscribe)
if err != nil {
l.Errorw("failed to find trial subscription template",
@@ -255,11 +310,13 @@ func (l *DeviceLoginLogic) activeTrial(userId int64, db *gorm.DB) error {
return err
}
// 计算试用期时间
startTime := time.Now()
expireTime := tool.AddTime(l.svcCtx.Config.Register.TrialTimeUnit, l.svcCtx.Config.Register.TrialTime, startTime)
subscribeToken := uuidx.SubscribeToken(fmt.Sprintf("Trial-%v", userId))
subscribeUUID := uuidx.NewUUID().String()
// 创建用户订阅记录
userSub := &user.Subscribe{
UserId: userId,
OrderId: 0,
+2 -1
View File
@@ -104,7 +104,8 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
// Update password
userInfo.Password = tool.EncodePassWord(req.Password)
if err := l.svcCtx.UserModel.Update(l.ctx, userInfo); err != nil {
userInfo.Algo = "default"
if err = l.svcCtx.UserModel.Update(l.ctx, userInfo); err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update user info failed: %v", err.Error())
}
+1 -1
View File
@@ -98,7 +98,7 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
if req.TelephoneCode == "" {
// Verify password
if !tool.VerifyPassWord(req.Password, userInfo.Password) {
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password")
}
} else {
@@ -78,6 +78,7 @@ func (l *TelephoneResetPasswordLogic) TelephoneResetPassword(req *types.Telephon
// Generate password
pwd := tool.EncodePassWord(req.Password)
userInfo.Password = pwd
userInfo.Algo = "default"
err = l.svcCtx.UserModel.Update(l.ctx, userInfo)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "update user password failed: %v", err.Error())
@@ -107,6 +107,7 @@ func (l *TelephoneUserRegisterLogic) TelephoneUserRegister(req *types.TelephoneR
pwd := tool.EncodePassWord(req.Password)
userInfo := &user.User{
Password: pwd,
Algo: "default",
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
AuthMethods: []user.AuthMethods{
{
+1
View File
@@ -90,6 +90,7 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
pwd := tool.EncodePassWord(req.Password)
userInfo := &user.User{
Password: pwd,
Algo: "default",
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
}
if referer != nil {
+1 -1
View File
@@ -57,7 +57,7 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
return err
}
// Verify sign
client := epay.NewClient(config.Pid, config.Url, config.Key)
client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type)
if !client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery)) && !l.svcCtx.Config.Debug {
l.Logger.Error("[EPayNotify] Verify sign failed")
return nil
+12 -1
View File
@@ -61,7 +61,18 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
)
return err
}
// All orders now have associated users, no special handling needed for guest orders
// If User ID is 0, it means that the order is a guest order and does not need to be refunded, the order can be deleted directly
if orderInfo.UserId == 0 {
err = tx.Model(&order.Order{}).Where("order_no = ?", req.OrderNo).Delete(&order.Order{}).Error
if err != nil {
l.Errorw("[CloseOrder] Delete order failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", req.OrderNo),
)
return err
}
return nil
}
// refund deduction amount to user deduction balance
if orderInfo.GiftAmount > 0 {
userInfo, err := l.svcCtx.UserModel.FindOne(l.ctx, orderInfo.UserId)
+2 -2
View File
@@ -3,7 +3,7 @@ package order
import "github.com/perfect-panel/server/internal/types"
func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64) float64 {
var finalDiscount float64 = 1.0
var finalDiscount int64 = 100
for _, discount := range discounts {
if inputMonths >= discount.Quantity && discount.Discount < finalDiscount {
@@ -11,5 +11,5 @@ func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64) float64
}
}
return finalDiscount
return float64(finalDiscount) / float64(100)
}
@@ -29,6 +29,43 @@ func NewGetSubscriptionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *G
}
}
func (l *GetSubscriptionLogic) GetSubscription1(req *types.GetSubscriptionRequest) (resp *types.GetSubscriptionResponse, err error) {
resp = &types.GetSubscriptionResponse{
List: make([]types.Subscribe, 0),
}
// Get the subscription list
_, data, err := l.svcCtx.SubscribeModel.FilterList(l.ctx, &subscribe.FilterParams{
Page: 1,
Size: 9999,
Show: true,
Language: req.Language,
DefaultLanguage: true,
})
if err != nil {
l.Errorw("[Site GetSubscription]", logger.Field("err", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get subscription list error: %v", err.Error())
}
list := make([]types.Subscribe, len(data))
for i, item := range data {
var sub types.Subscribe
tool.DeepCopy(&sub, item)
if item.Discount != "" {
var discount []types.SubscribeDiscount
_ = json.Unmarshal([]byte(item.Discount), &discount)
sub.Discount = discount
list[i] = sub
}
list[i] = sub
}
resp.List = list
return
}
// CountNodesByIdsAndTags 根据节点ID和标签计算启用的节点数量
func (l *GetSubscriptionLogic) CountNodesByIdsAndTags(ctx context.Context, nodeIds []int64, tags []string) (int64, error) {
return l.svcCtx.NodeModel.CountNodesByIdsAndTags(ctx, nodeIds, tags)
}
func (l *GetSubscriptionLogic) GetSubscription(req *types.GetSubscriptionRequest) (resp *types.GetSubscriptionResponse, err error) {
resp = &types.GetSubscriptionResponse{
List: make([]types.Subscribe, 0),
@@ -54,7 +91,7 @@ func (l *GetSubscriptionLogic) GetSubscription(req *types.GetSubscriptionRequest
_ = json.Unmarshal([]byte(item.Discount), &discount)
sub.Discount = discount
}
// 计算节点数量
nodeIds := tool.StringToInt64Slice(item.Nodes)
var nodeTags []string
@@ -66,7 +103,7 @@ func (l *GetSubscriptionLogic) GetSubscription(req *types.GetSubscriptionRequest
}
}
}
nodeCount, err := l.svcCtx.NodeModel.CountNodesByIdsAndTags(l.ctx, nodeIds, nodeTags)
if err != nil {
l.Logger.Error("[GetSubscription] count nodes failed: ", logger.Field("error", err.Error()))
@@ -74,7 +111,7 @@ func (l *GetSubscriptionLogic) GetSubscription(req *types.GetSubscriptionRequest
} else {
sub.NodeCount = nodeCount
}
list[i] = sub
}
resp.List = list
@@ -267,7 +267,7 @@ func (l *PurchaseCheckoutLogic) epayPayment(config *payment.Payment, info *order
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Unmarshal error: %s", err.Error())
}
// Initialize EPay client with merchant credentials
client := epay.NewClient(epayConfig.Pid, epayConfig.Url, epayConfig.Key)
client := epay.NewClient(epayConfig.Pid, epayConfig.Url, epayConfig.Key, epayConfig.Type)
// Convert order amount to CNY using current exchange rate
amount, err := l.queryExchangeRate("CNY", info.Amount)
@@ -309,7 +309,7 @@ func (l *PurchaseCheckoutLogic) CryptoSaaSPayment(config *payment.Payment, info
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Unmarshal error: %s", err.Error())
}
// Initialize EPay client with merchant credentials
client := epay.NewClient(epayConfig.AccountID, epayConfig.Endpoint, epayConfig.SecretKey)
client := epay.NewClient(epayConfig.AccountID, epayConfig.Endpoint, epayConfig.SecretKey, epayConfig.Type)
// Convert order amount to CNY using current exchange rate
amount, err := l.queryExchangeRate("CNY", info.Amount)
@@ -347,6 +347,11 @@ func (l *PurchaseCheckoutLogic) queryExchangeRate(to string, src int64) (amount
// Convert cents to decimal amount
amount = float64(src) / float64(100)
if l.svcCtx.ExchangeRate != 0 && to == "CNY" {
amount = amount * l.svcCtx.ExchangeRate
return amount, nil
}
// Retrieve system currency configuration
currency, err := l.svcCtx.SystemModel.GetCurrencyConfig(l.ctx)
if err != nil {
@@ -83,6 +83,12 @@ func (l *PurchaseLogic) Purchase(req *types.PortalPurchaseRequest) (resp *types.
if couponInfo.Count != 0 && couponInfo.Count <= couponInfo.UsedCount {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponInsufficientUsage), "coupon used")
}
// Check expiration time
expireTime := time.Unix(couponInfo.ExpireTime, 0)
if time.Now().After(expireTime) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponExpired), "coupon expired")
}
couponSub := tool.StringToInt64Slice(couponInfo.Subscribe)
if len(couponSub) > 0 && !tool.Contains(couponSub, req.SubscribeId) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponNotApplicable), "coupon not match")
+2 -2
View File
@@ -7,14 +7,14 @@ import (
)
func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64) float64 {
var finalDiscount float64 = 1.0
var finalDiscount int64 = 100
for _, discount := range discounts {
if inputMonths >= discount.Quantity && discount.Discount < finalDiscount {
finalDiscount = discount.Discount
}
}
return finalDiscount
return float64(finalDiscount) / float64(100)
}
func calculateCoupon(amount int64, couponInfo *coupon.Coupon) int64 {
@@ -3,7 +3,6 @@ package subscribe
import (
"context"
"encoding/json"
"strings"
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/internal/svc"
@@ -54,35 +53,8 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList(req *types.QuerySubscribeLi
var discount []types.SubscribeDiscount
_ = json.Unmarshal([]byte(item.Discount), &discount)
sub.Discount = discount
list[i] = sub
}
// 计算节点数量
var nodeIds []int64
var tags []string
// 解析节点ID
if item.Nodes != "" {
nodeIds = tool.StringToInt64Slice(item.Nodes)
}
// 解析标签
if item.NodeTags != "" {
tagStrs := strings.Split(item.NodeTags, ",")
for _, tag := range tagStrs {
if tag != "" {
tags = append(tags, tag)
}
}
}
// 获取节点数量
nodeCount, err := l.svcCtx.NodeModel.CountNodesByIdsAndTags(l.ctx, nodeIds, tags)
if err != nil {
l.Errorw("[QuerySubscribeListLogic] Count nodes failed", logger.Field("error", err.Error()), logger.Field("subscribeId", item.Id))
nodeCount = 0 // 出错时设置为0
}
sub.NodeCount = nodeCount
list[i] = sub
}
resp.List = list
@@ -141,7 +141,6 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u
Name: n.Name,
Uuid: userSub.UUID,
Protocol: n.Protocol,
Protocols: server.Protocols,
Port: n.Port,
Address: n.Address,
Tags: strings.Split(n.Tags, ","),
@@ -1,175 +0,0 @@
package user
import (
"context"
"fmt"
"time"
"github.com/perfect-panel/server/internal/logic/auth"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/jwt"
"github.com/perfect-panel/server/pkg/uuidx"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
)
type BindEmailWithPasswordLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewBindEmailWithPasswordLogic Bind Email With Password
func NewBindEmailWithPasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BindEmailWithPasswordLogic {
return &BindEmailWithPasswordLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailWithPasswordRequest) (*types.LoginResponse, error) {
// 获取当前设备用户
currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
logger.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 验证邮箱和密码是否匹配现有用户
emailUser, err := l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "email not registered: %v", req.Email)
}
logger.WithContext(l.ctx).Error(err)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user by email failed: %v", err.Error())
}
// 验证密码
if !tool.VerifyPassWord(req.Password, emailUser.Password) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "password incorrect")
}
// 检查当前用户是否已经绑定了邮箱
currentEmailMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "email", currentUser.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByUserId error")
}
// 最终用户ID(可能是当前用户或邮箱用户)
finalUserId := currentUser.Id
// 如果当前用户已经绑定了邮箱,检查是否是同一个邮箱
if currentEmailMethod.Id > 0 {
// 如果绑定的是同一个邮箱,直接生成Token返回
if currentEmailMethod.AuthIdentifier == req.Email {
l.Infow("user is binding the same email that is already bound",
logger.Field("user_id", currentUser.Id),
logger.Field("email", req.Email),
)
// 直接使用当前用户ID生成Token
} else {
// 如果是不同的邮箱,不允许重复绑定
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "current user already has email bound")
}
} else {
// 检查该邮箱是否已经被其他用户绑定
existingEmailMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
}
// 如果邮箱已经被其他用户绑定,需要进行数据迁移
if existingEmailMethod.Id > 0 && existingEmailMethod.UserId != currentUser.Id {
// 调用设备绑定逻辑,这会触发数据迁移
bindLogic := auth.NewBindDeviceLogic(l.ctx, l.svcCtx)
// 获取当前用户的设备标识符
deviceMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "device", currentUser.Id)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByUserId device error")
}
if deviceMethod.Id == 0 {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "current user has no device identifier")
}
// 执行设备重新绑定,这会触发数据迁移
if err := bindLogic.BindDeviceToUser(deviceMethod.AuthIdentifier, "", "", emailUser.Id); err != nil {
l.Errorw("failed to bind device to email user",
logger.Field("current_user_id", currentUser.Id),
logger.Field("email_user_id", emailUser.Id),
logger.Field("device_identifier", deviceMethod.AuthIdentifier),
logger.Field("error", err.Error()),
)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "bind device to email user failed")
}
l.Infow("successfully bound device to email user with data migration",
logger.Field("current_user_id", currentUser.Id),
logger.Field("email_user_id", emailUser.Id),
logger.Field("device_identifier", deviceMethod.AuthIdentifier),
)
// 数据迁移后,使用邮箱用户的ID
finalUserId = emailUser.Id
} else {
// 邮箱未被绑定,直接为当前用户创建邮箱绑定
emailMethod := &user.AuthMethods{
UserId: currentUser.Id,
AuthType: "email",
AuthIdentifier: req.Email,
Verified: true, // 通过密码验证,直接设为已验证
}
if err := l.svcCtx.UserModel.InsertUserAuthMethods(l.ctx, emailMethod); err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "InsertUserAuthMethods error")
}
l.Infow("successfully bound email to current user",
logger.Field("user_id", currentUser.Id),
logger.Field("email", req.Email),
)
}
}
// 生成新的Token
sessionId := uuidx.NewUUID().String()
loginType := "device"
if l.ctx.Value(constant.LoginType) != nil {
loginType = l.ctx.Value(constant.LoginType).(string)
}
token, err := jwt.NewJwtToken(
l.svcCtx.Config.JwtAuth.AccessSecret,
time.Now().Unix(),
l.svcCtx.Config.JwtAuth.AccessExpire,
jwt.WithOption("UserId", finalUserId),
jwt.WithOption("SessionId", sessionId),
jwt.WithOption("LoginType", loginType),
)
if err != nil {
l.Logger.Error("[BindEmailWithPassword] token generate error", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
}
// 设置session缓存
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, finalUserId, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
}
return &types.LoginResponse{
Token: token,
}, nil
}
@@ -0,0 +1,341 @@
package user
import (
"context"
"fmt"
"time"
"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/internal/types"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/jwt"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type BindEmailWithVerificationLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewBindEmailWithVerificationLogic Bind Email With Verification
func NewBindEmailWithVerificationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BindEmailWithVerificationLogic {
return &BindEmailWithVerificationLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.BindEmailWithVerificationRequest) (*types.BindEmailWithVerificationResponse, error) {
// 获取当前用户
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
logger.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 获取当前用户的设备标识符
deviceIdentifier, err := l.getCurrentUserDeviceIdentifier(l.ctx, u.Id)
if err != nil {
l.Errorw("获取用户设备标识符失败", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "获取用户设备信息失败: %v", err)
}
// 检查邮箱是否已被其他用户绑定
existingMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
l.Errorw("查询邮箱绑定状态失败", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "查询邮箱绑定状态失败")
}
var emailUserId int64
if existingMethod != nil {
// 邮箱已存在,使用现有的邮箱用户
emailUserId = existingMethod.UserId
l.Infow("邮箱已存在,将设备转移到现有邮箱用户",
logger.Field("email", req.Email),
logger.Field("email_user_id", emailUserId))
} else {
// 邮箱不存在,创建新的邮箱用户
emailUserId, err = l.createEmailUser(req.Email)
if err != nil {
l.Errorw("创建邮箱用户失败", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "创建邮箱用户失败: %v", err)
}
l.Infow("创建新的邮箱用户",
logger.Field("email", req.Email),
logger.Field("email_user_id", emailUserId))
}
// 执行设备转移到邮箱用户
return l.transferDeviceToEmailUser(u.Id, emailUserId, deviceIdentifier)
}
// getCurrentUserDeviceIdentifier 获取当前用户的设备标识符
func (l *BindEmailWithVerificationLogic) getCurrentUserDeviceIdentifier(ctx context.Context, userId int64) (string, error) {
authMethods, err := l.svcCtx.UserModel.FindUserAuthMethods(ctx, userId)
if err != nil {
return "", err
}
// 查找设备认证方式
for _, method := range authMethods {
if method.AuthType == "device" {
return method.AuthIdentifier, nil
}
}
return "", errors.New("用户没有设备认证方式")
}
// checkIfPureDeviceUser 检查用户是否为纯设备用户(只有设备认证方式)
func (l *BindEmailWithVerificationLogic) checkIfPureDeviceUser(ctx context.Context, userId int64) (bool, string, error) {
authMethods, err := l.svcCtx.UserModel.FindUserAuthMethods(ctx, userId)
if err != nil {
l.Errorw("查询用户认证方式失败", logger.Field("error", err.Error()), logger.Field("user_id", userId))
return false, "", errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "查询用户认证方式失败")
}
// 检查是否只有一个设备认证方式
if len(authMethods) == 1 && authMethods[0].AuthType == "device" {
return true, authMethods[0].AuthIdentifier, nil
}
return false, "", nil
}
// transferDeviceToEmailUser 将设备从设备用户转移到邮箱用户
func (l *BindEmailWithVerificationLogic) transferDeviceToEmailUser(deviceUserId, emailUserId int64, deviceIdentifier string) (*types.BindEmailWithVerificationResponse, error) {
l.Infow("开始设备转移",
logger.Field("device_user_id", deviceUserId),
logger.Field("email_user_id", emailUserId),
logger.Field("device_identifier", deviceIdentifier))
// 1. 先获取当前用户的SessionId,用于后续清理
currentSessionId := ""
if sessionIdValue := l.ctx.Value(constant.CtxKeySessionID); sessionIdValue != nil {
currentSessionId = sessionIdValue.(string)
}
// 2. 在事务中执行设备转移
err := l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error {
// 1. 检查目标邮箱用户状态
_, err := l.svcCtx.UserModel.FindOne(l.ctx, emailUserId)
if err != nil {
l.Errorw("查询邮箱用户失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
return err
}
// 2. 检查设备是否已经关联到目标用户
existingDevice, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, deviceIdentifier)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
l.Errorw("查询设备信息失败", logger.Field("error", err.Error()), logger.Field("device_identifier", deviceIdentifier))
return err
}
if existingDevice != nil && existingDevice.UserId == emailUserId {
// 设备已经关联到目标用户,直接生成token
l.Infow("设备已关联到目标用户", logger.Field("device_id", existingDevice.Id))
return nil
}
// 3. 处理设备冲突 - 删除目标用户的现有设备记录(如果存在)
if existingDevice != nil && existingDevice.UserId != emailUserId {
l.Infow("删除冲突的设备记录", logger.Field("existing_device_id", existingDevice.Id), logger.Field("existing_user_id", existingDevice.UserId))
if err := db.Where("identifier = ? AND user_id = ?", deviceIdentifier, existingDevice.UserId).Delete(&user.Device{}).Error; err != nil {
l.Errorw("删除冲突设备记录失败", logger.Field("error", err.Error()))
return err
}
}
// 4. 更新user_auth_methods表 - 将设备认证方式转移到邮箱用户
if err := db.Model(&user.AuthMethods{}).
Where("user_id = ? AND auth_type = ? AND auth_identifier = ?", deviceUserId, "device", deviceIdentifier).
Update("user_id", emailUserId).Error; err != nil {
l.Errorw("更新设备认证方式失败", logger.Field("error", err.Error()))
return err
}
// 5. 更新user_device表 - 将设备记录转移到邮箱用户
if err := db.Model(&user.Device{}).
Where("user_id = ? AND identifier = ?", deviceUserId, deviceIdentifier).
Update("user_id", emailUserId).Error; err != nil {
l.Errorw("更新设备记录失败", logger.Field("error", err.Error()))
return err
}
// 6. 检查原始设备用户是否还有其他认证方式,如果没有则删除该用户
var remainingAuthMethods []user.AuthMethods
if err := db.Where("user_id = ?", deviceUserId).Find(&remainingAuthMethods).Error; err != nil {
l.Errorw("查询原始用户剩余认证方式失败", logger.Field("error", err.Error()), logger.Field("device_user_id", deviceUserId))
return err
}
if len(remainingAuthMethods) == 0 {
// 获取原始用户信息用于清除缓存
deviceUser, _ := l.svcCtx.UserModel.FindOne(l.ctx, deviceUserId)
// 原始用户没有其他认证方式,可以安全删除
if err := db.Where("id = ?", deviceUserId).Delete(&user.User{}).Error; err != nil {
l.Errorw("删除原始设备用户失败", logger.Field("error", err.Error()), logger.Field("device_user_id", deviceUserId))
return err
}
// 清除已删除用户的缓存
if deviceUser != nil {
l.svcCtx.UserModel.ClearUserCache(l.ctx, deviceUser)
}
l.Infow("已删除原始设备用户", logger.Field("device_user_id", deviceUserId))
} else {
l.Infow("原始用户还有其他认证方式,保留用户记录",
logger.Field("device_user_id", deviceUserId),
logger.Field("remaining_auth_count", len(remainingAuthMethods)))
}
l.Infow("设备转移成功",
logger.Field("device_user_id", deviceUserId),
logger.Field("email_user_id", emailUserId),
logger.Field("device_identifier", deviceIdentifier))
return nil
})
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "设备转移失败: %v", err)
}
// 3. 清理原用户的SessionId缓存(使旧token失效)
if currentSessionId != "" {
sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, currentSessionId)
if err := l.svcCtx.Redis.Del(l.ctx, sessionKey).Err(); err != nil {
l.Errorw("清理原SessionId缓存失败", logger.Field("error", err.Error()), logger.Field("session_id", currentSessionId))
// 不返回错误,继续执行
} else {
l.Infow("已清理原SessionId缓存", logger.Field("session_id", currentSessionId))
}
}
// 4. 生成新的JWT token
token, err := l.generateTokenForUser(emailUserId)
if err != nil {
return nil, err
}
// 5. 清除邮箱用户缓存(确保获取最新数据)
emailUser, _ := l.svcCtx.UserModel.FindOne(l.ctx, emailUserId)
if emailUser != nil {
l.svcCtx.UserModel.ClearUserCache(l.ctx, emailUser)
}
// 6. 清除设备相关缓存
l.clearDeviceRelatedCache(deviceIdentifier, deviceUserId, emailUserId)
return &types.BindEmailWithVerificationResponse{
Success: true,
Message: "设备关联成功",
Token: token,
UserId: emailUserId,
}, nil
}
// generateTokenForUser 为指定用户生成JWT token
func (l *BindEmailWithVerificationLogic) generateTokenForUser(userId int64) (string, error) {
// 生成JWT token
now := time.Now().Unix()
accessExpire := l.svcCtx.Config.JwtAuth.AccessExpire
sessionId := fmt.Sprintf("device_transfer_%d_%d", userId, now)
jwtToken, err := jwt.NewJwtToken(
l.svcCtx.Config.JwtAuth.AccessSecret,
now,
accessExpire,
jwt.WithOption("UserId", userId),
jwt.WithOption("SessionId", sessionId),
)
if err != nil {
l.Errorw("生成JWT token失败", logger.Field("error", err.Error()), logger.Field("user_id", userId))
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "生成token失败: %v", err)
}
// 设置session缓存
sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
if err := l.svcCtx.Redis.Set(l.ctx, sessionKey, userId, time.Duration(accessExpire)*time.Second).Err(); err != nil {
l.Errorw("设置session缓存失败", logger.Field("error", err.Error()), logger.Field("user_id", userId))
// session缓存失败不影响token生成,只记录错误
}
l.Infow("为用户生成token成功", logger.Field("user_id", userId))
return jwtToken, nil
}
// createEmailUser 创建新的邮箱用户
func (l *BindEmailWithVerificationLogic) createEmailUser(email string) (int64, error) {
var newUserId int64
err := l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
// 1. 创建新用户
enabled := true
newUser := &user.User{
Enable: &enabled, // 启用状态
}
if err := tx.Create(newUser).Error; err != nil {
l.Errorw("创建用户失败", logger.Field("error", err.Error()))
return err
}
newUserId = newUser.Id
l.Infow("创建新用户成功", logger.Field("user_id", newUserId))
// 2. 创建邮箱认证方法
emailAuth := &user.AuthMethods{
UserId: newUserId,
AuthType: "email",
AuthIdentifier: email,
Verified: true, // 直接设置为已验证
}
if err := tx.Create(emailAuth).Error; err != nil {
l.Errorw("创建邮箱认证方法失败", logger.Field("error", err.Error()))
return err
}
l.Infow("创建邮箱认证方法成功",
logger.Field("user_id", newUserId),
logger.Field("email", email))
return nil
})
if err != nil {
return 0, err
}
return newUserId, nil
}
// clearDeviceRelatedCache 清除设备相关缓存
func (l *BindEmailWithVerificationLogic) clearDeviceRelatedCache(deviceIdentifier string, oldUserId, newUserId int64) {
// 清除设备相关的缓存键
deviceCacheKeys := []string{
fmt.Sprintf("device:%s", deviceIdentifier),
fmt.Sprintf("user_device:%d", oldUserId),
fmt.Sprintf("user_device:%d", newUserId),
fmt.Sprintf("user_auth:%d", oldUserId),
fmt.Sprintf("user_auth:%d", newUserId),
}
for _, key := range deviceCacheKeys {
if err := l.svcCtx.Redis.Del(l.ctx, key).Err(); err != nil {
l.Errorw("清除设备缓存失败", logger.Field("error", err.Error()), logger.Field("cache_key", key))
} else {
l.Infow("已清除设备缓存", logger.Field("cache_key", key))
}
}
}
@@ -41,7 +41,11 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "device not belong to user")
}
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
// 保存设备信息用于后续缓存清理
deviceIdentifier := device.Identifier
userId := device.UserId
err = l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
var deleteDevice user.Device
err = tx.Model(&deleteDevice).Where("id = ?", req.Id).First(&deleteDevice).Error
if err != nil {
@@ -55,6 +59,9 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
err = tx.Model(&userAuth).Where("auth_identifier = ? and auth_type = ?", deleteDevice.Identifier, "device").First(&userAuth).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
l.Infow("设备认证方法不存在,可能已被删除",
logger.Field("device_identifier", deleteDevice.Identifier),
logger.Field("user_id", userId))
return nil
}
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find device online record err: %v", err)
@@ -64,9 +71,69 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete device online record err: %v", err)
}
sessionId := l.ctx.Value(constant.CtxKeySessionID)
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey)
l.Infow("设备解绑成功",
logger.Field("device_id", req.Id),
logger.Field("device_identifier", deviceIdentifier),
logger.Field("user_id", userId))
return nil
})
if err != nil {
return err
}
// 事务成功后进行缓存清理
l.clearUnbindDeviceCache(deviceIdentifier, userId, userInfo)
return nil
}
// clearUnbindDeviceCache 清除设备解绑相关的缓存
func (l *UnbindDeviceLogic) clearUnbindDeviceCache(deviceIdentifier string, userId int64, userInfo *user.User) {
// 1. 清除当前SessionId缓存(使当前token失效)
if sessionId := l.ctx.Value(constant.CtxKeySessionID); sessionId != nil {
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
if err := l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey).Err(); err != nil {
l.Errorw("清理SessionId缓存失败",
logger.Field("error", err.Error()),
logger.Field("session_id", sessionId))
} else {
l.Infow("已清理SessionId缓存", logger.Field("session_id", sessionId))
}
}
// 2. 清除用户缓存
if err := l.svcCtx.UserModel.ClearUserCache(l.ctx, userInfo); err != nil {
l.Errorw("清理用户缓存失败",
logger.Field("error", err.Error()),
logger.Field("user_id", userId))
} else {
l.Infow("已清理用户缓存", logger.Field("user_id", userId))
}
// 3. 清除设备相关缓存
l.clearDeviceRelatedCache(deviceIdentifier, userId)
}
// clearDeviceRelatedCache 清除设备相关缓存
func (l *UnbindDeviceLogic) clearDeviceRelatedCache(deviceIdentifier string, userId int64) {
// 清除设备相关的缓存键
deviceCacheKeys := []string{
fmt.Sprintf("device:%s", deviceIdentifier),
fmt.Sprintf("user_device:%d", userId),
fmt.Sprintf("user_auth:%d", userId),
fmt.Sprintf("device_auth:%s", deviceIdentifier),
}
for _, key := range deviceCacheKeys {
if err := l.svcCtx.Redis.Del(l.ctx, key).Err(); err != nil {
l.Errorw("清除设备缓存失败",
logger.Field("error", err.Error()),
logger.Field("cache_key", key))
} else {
l.Infow("已清除设备缓存", logger.Field("cache_key", key))
}
}
}
@@ -30,50 +30,37 @@ func NewUpdateBindEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *U
}
}
// UpdateBindEmail 更新用户绑定的邮箱地址
// 该方法用于用户更新或绑定新的邮箱地址,支持首次绑定和修改已绑定邮箱
func (l *UpdateBindEmailLogic) UpdateBindEmail(req *types.UpdateBindEmailRequest) error {
// 从上下文中获取当前用户信息
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
logger.Error("current user is not found in context")
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 查询当前用户是否已有邮箱认证方式
method, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "email", u.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
}
// 检查要绑定的邮箱是否已被其他用户使用
m, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
}
// 如果邮箱已被绑定,返回错误
// email already bind
if m.Id > 0 {
return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "email already bind")
}
// 如果用户还没有邮箱认证方式,创建新的认证记录
if method.Id == 0 {
method = &user.AuthMethods{
UserId: u.Id, // 用户ID
AuthType: "email", // 认证类型为邮箱
AuthIdentifier: req.Email, // 邮箱地址
Verified: false, // 初始状态为未验证
UserId: u.Id,
AuthType: "email",
AuthIdentifier: req.Email,
Verified: false,
}
// 插入新的认证方式记录
if err := l.svcCtx.UserModel.InsertUserAuthMethods(l.ctx, method); err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "InsertUserAuthMethods error")
}
} else {
// 如果用户已有邮箱认证方式,更新邮箱地址
method.Verified = false // 重置验证状态
method.AuthIdentifier = req.Email // 更新邮箱地址
// 更新认证方式记录
method.Verified = false
method.AuthIdentifier = req.Email
if err := l.svcCtx.UserModel.UpdateUserAuthMethods(l.ctx, method); err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "UpdateUserAuthMethods error")
}
+5 -3
View File
@@ -85,9 +85,10 @@ func (l *AlipayF2FConfig) Unmarshal(data []byte) error {
}
type EPayConfig struct {
Pid string `json:"pid"`
Url string `json:"url"`
Key string `json:"key"`
Pid string `json:"pid"`
Url string `json:"url"`
Key string `json:"key"`
Type string `json:"type"`
}
func (l *EPayConfig) Marshal() ([]byte, error) {
@@ -109,6 +110,7 @@ type CryptoSaaSConfig struct {
Endpoint string `json:"endpoint"`
AccountID string `json:"account_id"`
SecretKey string `json:"secret_key"`
Type string `json:"type"`
}
func (l *CryptoSaaSConfig) Marshal() ([]byte, error) {
+2 -2
View File
@@ -71,8 +71,8 @@ func (s *Subscribe) BeforeUpdate(tx *gorm.DB) error {
}
type Discount struct {
Months int64 `json:"months"`
Discount float64 `json:"discount"`
Months int64 `json:"months"`
Discount int64 `json:"discount"`
}
type Group struct {
+4 -1
View File
@@ -20,7 +20,10 @@ func (m *defaultUserModel) FindUserAuthMethodByOpenID(ctx context.Context, metho
err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&AuthMethods{}).Where("auth_type = ? AND auth_identifier = ?", method, openID).First(&data).Error
})
return &data, err
if err != nil {
return nil, err
}
return &data, nil
}
func (m *defaultUserModel) FindUserAuthMethodByPlatform(ctx context.Context, userId int64, platform string) (*AuthMethods, error) {
+15 -1
View File
@@ -77,7 +77,6 @@ type customUserLogicModel interface {
QueryUserSubscribe(ctx context.Context, userId int64, status ...int64) ([]*SubscribeDetails, error)
FindOneSubscribeDetailsById(ctx context.Context, id int64) (*SubscribeDetails, error)
FindOneUserSubscribe(ctx context.Context, id int64) (*SubscribeDetails, error)
FindActiveSubscribe(ctx context.Context, userId int64) (*Subscribe, error)
FindUsersSubscribeBySubscribeId(ctx context.Context, subscribeId int64) ([]*Subscribe, error)
UpdateUserSubscribeWithTraffic(ctx context.Context, id, download, upload int64, tx ...*gorm.DB) error
QueryResisterUserTotalByDate(ctx context.Context, date time.Time) (int64, error)
@@ -87,6 +86,7 @@ type customUserLogicModel interface {
UpdateUserCache(ctx context.Context, data *User) error
UpdateUserSubscribeCache(ctx context.Context, data *Subscribe) error
QueryActiveSubscriptions(ctx context.Context, subscribeId ...int64) (map[int64]int64, error)
FindActiveSubscribe(ctx context.Context, userId int64) (*Subscribe, error)
FindUserAuthMethods(ctx context.Context, userId int64) ([]*AuthMethods, error)
InsertUserAuthMethods(ctx context.Context, data *AuthMethods, tx ...*gorm.DB) error
UpdateUserAuthMethods(ctx context.Context, data *AuthMethods, tx ...*gorm.DB) error
@@ -288,6 +288,20 @@ func (m *customUserModel) QueryDailyUserStatisticsList(ctx context.Context, date
return results, err
}
// FindActiveSubscribe 查找用户的活跃订阅
func (m *customUserModel) FindActiveSubscribe(ctx context.Context, userId int64) (*Subscribe, error) {
var subscribe Subscribe
err := m.QueryNoCacheCtx(ctx, &subscribe, func(conn *gorm.DB, v interface{}) error {
return conn.Where("user_id = ? AND status IN (0, 1) AND expire_time > ?", userId, time.Now()).
Order("expire_time DESC").
First(v).Error
})
if err != nil {
return nil, err
}
return &subscribe, nil
}
// QueryMonthlyUserStatisticsList Query monthly user statistics list for the past 6 months
func (m *customUserModel) QueryMonthlyUserStatisticsList(ctx context.Context, date time.Time) ([]UserStatisticsWithDate, error) {
var results []UserStatisticsWithDate
-21
View File
@@ -198,24 +198,3 @@ func (m *defaultUserModel) DeleteSubscribeById(ctx context.Context, id int64, tx
func (m *defaultUserModel) ClearSubscribeCache(ctx context.Context, data ...*Subscribe) error {
return m.ClearSubscribeCacheByModels(ctx, data...)
}
// FindActiveSubscribe finds the user's active subscription
func (m *defaultUserModel) FindActiveSubscribe(ctx context.Context, userId int64) (*Subscribe, error) {
var data Subscribe
err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, v interface{}) error {
now := time.Now()
return conn.Model(&Subscribe{}).
Where("user_id = ? AND status = ? AND expire_time > ?", userId, 1, now).
Order("expire_time DESC").
First(&data).Error
})
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &data, nil
}
+23 -20
View File
@@ -4,27 +4,30 @@ import (
"time"
)
// User 用户模型结构体
type User struct {
Id int64 `gorm:"primaryKey"`
Password string `gorm:"type:varchar(100);not null;comment:User Password"`
Avatar string `gorm:"type:MEDIUMTEXT;comment:User Avatar"`
Balance int64 `gorm:"default:0;comment:User Balance"` // User Balance Amount
ReferCode string `gorm:"type:varchar(20);default:'';comment:Referral Code"`
RefererId int64 `gorm:"index:idx_referer;comment:Referrer ID"`
Commission int64 `gorm:"default:0;comment:Commission"` // Commission Amount
ReferralPercentage uint8 `gorm:"default:0;comment:Referral"` // Referral Percentage
OnlyFirstPurchase *bool `gorm:"default:true;not null;comment:Only First Purchase"` // Only First Purchase Referral
GiftAmount int64 `gorm:"default:0;comment:User Gift Amount"`
Enable *bool `gorm:"default:true;not null;comment:Is Account Enabled"`
IsAdmin *bool `gorm:"default:false;not null;comment:Is Admin"`
EnableBalanceNotify *bool `gorm:"default:false;not null;comment:Enable Balance Change Notifications"`
EnableLoginNotify *bool `gorm:"default:false;not null;comment:Enable Login Notifications"`
EnableSubscribeNotify *bool `gorm:"default:false;not null;comment:Enable Subscription Notifications"`
EnableTradeNotify *bool `gorm:"default:false;not null;comment:Enable Trade Notifications"`
AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"`
UserDevices []Device `gorm:"foreignKey:UserId;references:Id"`
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
Id int64 `gorm:"primaryKey"` // 用户主键ID
Password string `gorm:"type:varchar(100);not null;comment:User Password"` // 用户密码(加密存储)
Algo string `gorm:"type:varchar(20);default:'default';comment:Encryption Algorithm"` // 密码加密算法
Salt string `gorm:"type:varchar(20);default:null;comment:Password Salt"` // 密码盐值
Avatar string `gorm:"type:MEDIUMTEXT;comment:User Avatar"` // 用户头像
Balance int64 `gorm:"default:0;comment:User Balance"` // 用户余额(以分为单位)
ReferCode string `gorm:"type:varchar(20);default:'';comment:Referral Code"` // 用户推荐码
RefererId int64 `gorm:"index:idx_referer;comment:Referrer ID"` // 推荐人ID
Commission int64 `gorm:"default:0;comment:Commission"` // 佣金金额
ReferralPercentage uint8 `gorm:"default:0;comment:Referral"` // 推荐奖励百分比
OnlyFirstPurchase *bool `gorm:"default:true;not null;comment:Only First Purchase"` // 是否仅首次购买给推荐奖励
GiftAmount int64 `gorm:"default:0;comment:User Gift Amount"` // 用户赠送金额
Enable *bool `gorm:"default:true;not null;comment:Is Account Enabled"` // 账户是否启用
IsAdmin *bool `gorm:"default:false;not null;comment:Is Admin"` // 是否为管理员
EnableBalanceNotify *bool `gorm:"default:false;not null;comment:Enable Balance Change Notifications"` // 是否启用余额变动通知
EnableLoginNotify *bool `gorm:"default:false;not null;comment:Enable Login Notifications"` // 是否启用登录通知
EnableSubscribeNotify *bool `gorm:"default:false;not null;comment:Enable Subscription Notifications"` // 是否启用订阅通知
EnableTradeNotify *bool `gorm:"default:false;not null;comment:Enable Trade Notifications"` // 是否启用交易通知
AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"` // 用户认证方式列表
UserDevices []Device `gorm:"foreignKey:UserId;references:Id"` // 用户设备列表
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` // 创建时间
UpdatedAt time.Time `gorm:"comment:Update Time"` // 更新时间
}
func (*User) TableName() string {
+11 -8
View File
@@ -32,10 +32,12 @@ import (
)
type ServiceContext struct {
DB *gorm.DB
Redis *redis.Client
Config config.Config
Queue *asynq.Client
DB *gorm.DB
Redis *redis.Client
Config config.Config
Queue *asynq.Client
ExchangeRate float64
//NodeCache *cache.NodeCacheClient
AuthModel auth.Model
AdsModel ads.Model
@@ -82,10 +84,11 @@ func NewServiceContext(c config.Config) *ServiceContext {
}
authLimiter := limit.NewPeriodLimit(86400, 15, rds, config.SendCountLimitKeyPrefix, limit.Align())
srv := &ServiceContext{
DB: db,
Redis: rds,
Config: c,
Queue: NewAsynqClient(c),
DB: db,
Redis: rds,
Config: c,
Queue: NewAsynqClient(c),
ExchangeRate: 1.0,
//NodeCache: cache.NewNodeCacheClient(rds),
AuthLimiter: authLimiter,
AdsModel: ads.NewModel(db, rds),
+18 -13
View File
@@ -178,15 +178,6 @@ type BatchSendEmailTask struct {
UpdatedAt int64 `json:"updated_at"`
}
type BindEmailWithPasswordRequest struct {
Email string `json:"email" validate:"required"`
Password string `json:"password" validate:"required"`
}
type BindInviteCodeRequest struct {
InviteCode string `json:"invite_code" validate:"required"`
}
type BindOAuthCallbackRequest struct {
Method string `json:"method"`
Callback interface{} `json:"callback"`
@@ -201,6 +192,10 @@ type BindOAuthResponse struct {
Redirect string `json:"redirect"`
}
type BindInviteCodeRequest struct {
InviteCode string `json:"invite_code" validate:"required"`
}
type BindTelegramResponse struct {
Url string `json:"url"`
ExpiredAt int64 `json:"expired_at"`
@@ -1179,7 +1174,6 @@ type InviteConfig struct {
ForcedInvite bool `json:"forced_invite"`
ReferralPercentage int64 `json:"referral_percentage"`
OnlyFirstPurchase bool `json:"only_first_purchase"`
GiftDays int64 `json:"gift_days"`
}
type KickOfflineRequest struct {
@@ -2076,8 +2070,8 @@ type SubscribeConfig struct {
}
type SubscribeDiscount struct {
Quantity int64 `json:"quantity"`
Discount float64 `json:"discount"`
Quantity int64 `json:"quantity"`
Discount int64 `json:"discount"`
}
type SubscribeGroup struct {
@@ -2645,7 +2639,6 @@ type UserSubscribeNodeInfo struct {
Name string `json:"name"`
Uuid string `json:"uuid"`
Protocol string `json:"protocol"`
Protocols string `json:"protocols"`
Port uint16 `json:"port"`
Address string `json:"address"`
Tags []string `json:"tags"`
@@ -2702,6 +2695,18 @@ type VerifyEmailRequest struct {
Code string `json:"code" validate:"required"`
}
type BindEmailWithVerificationRequest struct {
Email string `json:"email" validate:"required"`
Code string `json:"code" validate:"required"`
}
type BindEmailWithVerificationResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Token string `json:"token,omitempty"` // 设备关联后的新Token
UserId int64 `json:"user_id,omitempty"` // 目标用户ID
}
type VersionResponse struct {
Version string `json:"version"`
}