**问题**: 邮箱绑定后应该赠送 trial 的逻辑判断错误,导致以下场景无法赠送:
- EnableTrial=true, EnableTrialEmailWhitelist=false → 应该赠送但未赠送
**根本原因**: 第215行条件判断使用 OR 逻辑,要求白名单必须启用才处理
```go
if !rc.EnableTrial || !rc.EnableTrialEmailWhitelist {
return // ❌ 错误:关闭白名单时也返回,无法赠送
}
```
**修复**: 改为正确的逻辑
```go
if !rc.EnableTrial {
return // 关闭赠送时不处理
}
if rc.EnableTrialEmailWhitelist && !IsEmailDomainWhitelisted(...) {
return // 白名单启用但域名不匹配时不赠送
}
// 否则赠送
```
**影响场景**:
- 设备登录 → 绑定邮箱 → 应该赠送 trial
- 当 EnableTrialEmailWhitelist=false 时,应该赠送(修复前未赠送)
- 当 EnableTrialEmailWhitelist=true 且域名在白名单 → 赠送(修复前未赠送)
Co-Authored-By: claude-flow <ruv@ruv.net>
281 lines
9.2 KiB
Go
281 lines
9.2 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/perfect-panel/server/internal/config"
|
|
"github.com/perfect-panel/server/internal/logic/auth"
|
|
"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/tool"
|
|
"github.com/perfect-panel/server/pkg/uuidx"
|
|
"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) {
|
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
|
|
|
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
|
if !ok {
|
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
|
}
|
|
|
|
type payload struct {
|
|
Code string `json:"code"`
|
|
LastAt int64 `json:"lastAt"`
|
|
}
|
|
var verified bool
|
|
scenes := []string{constant.Security.String(), constant.Register.String()}
|
|
for _, scene := range scenes {
|
|
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
|
|
value, getErr := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
|
if getErr != nil || value == "" {
|
|
continue
|
|
}
|
|
var p payload
|
|
if err := json.Unmarshal([]byte(value), &p); err != nil {
|
|
continue
|
|
}
|
|
if p.Code == req.Code && time.Now().Unix()-p.LastAt <= l.svcCtx.Config.VerifyCode.VerifyCodeExpireTime {
|
|
_ = l.svcCtx.Redis.Del(l.ctx, cacheKey).Err()
|
|
verified = true
|
|
break
|
|
}
|
|
}
|
|
if !verified {
|
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error or expired")
|
|
}
|
|
|
|
familyHelper := newFamilyBindingHelper(l.ctx, l.svcCtx)
|
|
currentEmailMethod, err := familyHelper.getUserEmailMethod(u.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if currentEmailMethod != nil {
|
|
if currentEmailMethod.AuthIdentifier == req.Email {
|
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "email already bound to current user")
|
|
}
|
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "email already bound to another account")
|
|
}
|
|
|
|
existingMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
|
|
if err != nil {
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query email bind status failed")
|
|
}
|
|
|
|
// Create a new email user and establish family relationship
|
|
var emailUser *user.User
|
|
if txErr := l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
|
emailUser = &user.User{
|
|
Salt: "default",
|
|
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
|
|
}
|
|
if err := tx.Create(emailUser).Error; err != nil {
|
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create email user failed: %v", err)
|
|
}
|
|
emailUser.ReferCode = uuidx.UserInviteCode(emailUser.Id)
|
|
if err := tx.Model(&user.User{}).Where("id = ?", emailUser.Id).Update("refer_code", emailUser.ReferCode).Error; err != nil {
|
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update refer code failed: %v", err)
|
|
}
|
|
authInfo := &user.AuthMethods{
|
|
UserId: emailUser.Id,
|
|
AuthType: "email",
|
|
AuthIdentifier: req.Email,
|
|
Verified: true,
|
|
}
|
|
if err := tx.Create(authInfo).Error; err != nil {
|
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create email auth method failed: %v", err)
|
|
}
|
|
return nil
|
|
}); txErr != nil {
|
|
return nil, txErr
|
|
}
|
|
|
|
// Join family: email user as owner, device user as member
|
|
if err = familyHelper.validateJoinFamily(emailUser.Id, u.Id); err != nil {
|
|
return nil, err
|
|
}
|
|
joinResult, err := familyHelper.joinFamily(emailUser.Id, u.Id, "bind_email_with_verification")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
token, err := l.refreshBindSessionToken(u.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Grant trial subscription if email domain is whitelisted
|
|
l.tryGrantTrialOnEmailBind(emailUser.Id, req.Email)
|
|
return &types.BindEmailWithVerificationResponse{
|
|
Success: true,
|
|
Message: "email user created and joined family",
|
|
Token: token,
|
|
UserId: u.Id,
|
|
FamilyJoined: true,
|
|
FamilyId: joinResult.FamilyId,
|
|
OwnerUserId: joinResult.OwnerUserId,
|
|
}, nil
|
|
}
|
|
|
|
if existingMethod.UserId == u.Id {
|
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "email already bound to current user")
|
|
}
|
|
|
|
if err = familyHelper.validateJoinFamily(existingMethod.UserId, u.Id); err != nil {
|
|
return nil, err
|
|
}
|
|
joinResult, err := familyHelper.joinFamily(existingMethod.UserId, u.Id, "bind_email_with_verification")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
token, err := l.refreshBindSessionToken(u.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Grant trial subscription if email domain is whitelisted
|
|
l.tryGrantTrialOnEmailBind(existingMethod.UserId, req.Email)
|
|
|
|
return &types.BindEmailWithVerificationResponse{
|
|
Success: true,
|
|
Message: "joined family successfully",
|
|
Token: token,
|
|
UserId: u.Id,
|
|
FamilyJoined: true,
|
|
FamilyId: joinResult.FamilyId,
|
|
OwnerUserId: joinResult.OwnerUserId,
|
|
}, nil
|
|
}
|
|
|
|
func (l *BindEmailWithVerificationLogic) refreshBindSessionToken(userId int64) (string, error) {
|
|
sessionId, _ := l.ctx.Value(constant.CtxKeySessionID).(string)
|
|
if sessionId == "" {
|
|
sessionId = uuidx.NewUUID().String()
|
|
}
|
|
|
|
opts := []jwt.Option{
|
|
jwt.WithOption("UserId", userId),
|
|
jwt.WithOption("SessionId", sessionId),
|
|
}
|
|
if loginType, ok := l.ctx.Value(constant.CtxLoginType).(string); ok && loginType != "" {
|
|
opts = append(opts, jwt.WithOption("CtxLoginType", loginType))
|
|
}
|
|
if identifier, ok := l.ctx.Value(constant.CtxKeyIdentifier).(string); ok && identifier != "" {
|
|
opts = append(opts, jwt.WithOption("identifier", identifier))
|
|
}
|
|
|
|
token, err := jwt.NewJwtToken(
|
|
l.svcCtx.Config.JwtAuth.AccessSecret,
|
|
time.Now().Unix(),
|
|
l.svcCtx.Config.JwtAuth.AccessExpire,
|
|
opts...,
|
|
)
|
|
if err != nil {
|
|
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
|
}
|
|
|
|
expire := time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire) * time.Second
|
|
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
|
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userId, expire).Err(); err != nil {
|
|
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
// tryGrantTrialOnEmailBind grants trial subscription to the email user (family owner)
|
|
// if EnableTrial is on and (if whitelist is enabled, email domain must match).
|
|
func (l *BindEmailWithVerificationLogic) tryGrantTrialOnEmailBind(ownerUserId int64, email string) {
|
|
rc := l.svcCtx.Config.Register
|
|
if !rc.EnableTrial {
|
|
return
|
|
}
|
|
if rc.EnableTrialEmailWhitelist && !auth.IsEmailDomainWhitelisted(email, rc.TrialEmailDomainWhitelist) {
|
|
l.Infow("email domain not in trial whitelist, skip",
|
|
logger.Field("email", email),
|
|
logger.Field("owner_user_id", ownerUserId),
|
|
)
|
|
return
|
|
}
|
|
|
|
// Anti-duplicate: check if owner already has trial subscription
|
|
var count int64
|
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
|
Model(&user.Subscribe{}).
|
|
Where("user_id = ? AND subscribe_id = ?", ownerUserId, rc.TrialSubscribe).
|
|
Count(&count).Error; err != nil {
|
|
l.Errorw("failed to check existing trial", logger.Field("error", err.Error()))
|
|
return
|
|
}
|
|
if count > 0 {
|
|
l.Infow("trial already granted, skip",
|
|
logger.Field("owner_user_id", ownerUserId),
|
|
)
|
|
return
|
|
}
|
|
|
|
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, rc.TrialSubscribe)
|
|
if err != nil {
|
|
l.Errorw("failed to find trial subscribe template", logger.Field("error", err.Error()))
|
|
return
|
|
}
|
|
|
|
userSub := &user.Subscribe{
|
|
UserId: ownerUserId,
|
|
OrderId: 0,
|
|
SubscribeId: sub.Id,
|
|
StartTime: time.Now(),
|
|
ExpireTime: tool.AddTime(rc.TrialTimeUnit, rc.TrialTime, time.Now()),
|
|
Traffic: sub.Traffic,
|
|
Download: 0,
|
|
Upload: 0,
|
|
Token: uuidx.NewUUID().String(),
|
|
UUID: uuidx.NewUUID().String(),
|
|
Status: 1,
|
|
}
|
|
if err = l.svcCtx.UserModel.InsertSubscribe(l.ctx, userSub); err != nil {
|
|
l.Errorw("failed to insert trial subscribe",
|
|
logger.Field("error", err.Error()),
|
|
logger.Field("owner_user_id", ownerUserId),
|
|
)
|
|
return
|
|
}
|
|
|
|
// InsertSubscribe auto-clears user subscribe cache via execSubscribeMutation.
|
|
// Clear server cache so nodes pick up the new subscription.
|
|
if err = l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil {
|
|
l.Errorw("ClearServerAllCache error", logger.Field("error", err.Error()))
|
|
}
|
|
|
|
l.Infow("trial granted on email bind",
|
|
logger.Field("owner_user_id", ownerUserId),
|
|
logger.Field("email", email),
|
|
logger.Field("subscribe_id", sub.Id),
|
|
)
|
|
}
|