补单逻辑
Build docker and publish / build (20.15.1) (push) Has been cancelled

This commit is contained in:
2026-05-06 04:59:04 -07:00
parent 463d3e2315
commit 54daa923da
18 changed files with 15992 additions and 2 deletions
@@ -0,0 +1,350 @@
package recovery
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
ordermodel "github.com/perfect-panel/server/internal/model/order"
recoverymodel "github.com/perfect-panel/server/internal/model/recovery"
subscribemodel "github.com/perfect-panel/server/internal/model/subscribe"
usermodel "github.com/perfect-panel/server/internal/model/user"
recoveryconfig "github.com/perfect-panel/server/internal/recovery"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/authmethod"
"github.com/perfect-panel/server/pkg/constant"
"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"
pkgerrors "github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const genericRecoveryError = "order recovery information is invalid"
type RecoverOrderLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewRecoverOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RecoverOrderLogic {
return &RecoverOrderLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *RecoverOrderLogic) RecoverOrder(req *types.RecoverOrderRequest) (*types.RecoverOrderResponse, error) {
orderNo := strings.TrimSpace(req.OrderNo)
email := strings.ToLower(strings.TrimSpace(req.Email))
code := strings.TrimSpace(req.Code)
if orderNo == "" || email == "" || code == "" {
return nil, pkgerrors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), genericRecoveryError)
}
if err := l.verifyEmailCode(email, code); err != nil {
return nil, err
}
item, ok, err := recoveryconfig.FindOrder(orderNo)
if err != nil {
l.Errorw("[RecoverOrder] load recovery config failed", logger.Field("error", err.Error()))
return nil, pkgerrors.Wrapf(xerr.NewErrCode(xerr.ERROR), genericRecoveryError)
}
if !ok {
return nil, pkgerrors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), genericRecoveryError)
}
if item.OrderStatus != "" && item.OrderStatus != "success" {
return nil, pkgerrors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), genericRecoveryError)
}
if item.Quantity <= 0 || item.OrderMoneyCents <= 0 {
return nil, pkgerrors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), genericRecoveryError)
}
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, item.SubscribeId)
if err != nil {
l.Errorw("[RecoverOrder] subscribe not found", logger.Field("subscribe_id", item.SubscribeId), logger.Field("error", err.Error()))
return nil, pkgerrors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), genericRecoveryError)
}
var (
claim recoverymodel.OrderRecoveryClaim
order ordermodel.Order
userSub usermodel.Subscribe
)
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Model(&recoverymodel.OrderRecoveryClaim{}).
Where("order_no = ?", orderNo).
First(&claim).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
u, err := l.findOrCreateUser(tx, email)
if err != nil {
return err
}
claim = recoverymodel.OrderRecoveryClaim{
OrderNo: orderNo,
Email: email,
UserId: u.Id,
SubscribeId: item.SubscribeId,
ClaimedAt: time.Now(),
}
if err := tx.Model(&recoverymodel.OrderRecoveryClaim{}).Create(&claim).Error; err != nil {
return err
}
order, err = l.createRecoveredOrder(tx, u.Id, item, sub)
if err != nil {
return err
}
userSub, err = l.createOrExtendSubscribe(tx, u.Id, order.Id, item, sub)
if err != nil {
return err
}
claim.UserSubscribeId = userSub.Id
return tx.Model(&recoverymodel.OrderRecoveryClaim{}).
Where("id = ?", claim.Id).
Updates(map[string]any{
"order_id": order.Id,
"user_subscribe_id": userSub.Id,
}).Error
})
if err != nil {
l.Errorw("[RecoverOrder] recover failed", logger.Field("order_no", orderNo), logger.Field("error", err.Error()))
return nil, pkgerrors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), genericRecoveryError)
}
l.clearCaches(&userSub, item.SubscribeId, email)
l.Infof("[RecoverOrder] recovered order subscription order_no=%s email=%s user_id=%d subscribe_id=%d user_subscribe_id=%d",
orderNo, email, claim.UserId, claim.SubscribeId, claim.UserSubscribeId)
return &types.RecoverOrderResponse{
Success: true,
Message: "recovery completed, please login with your email verification code",
}, nil
}
func (l *RecoverOrderLogic) createRecoveredOrder(tx *gorm.DB, userId int64, item recoveryconfig.Order, sub *subscribemodel.Subscribe) (ordermodel.Order, error) {
var existing ordermodel.Order
orderNo := item.OutOrderNo
if orderNo == "" {
orderNo = item.OrderNo
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Model(&ordermodel.Order{}).
Where("order_no = ?", orderNo).
First(&existing).Error; err == nil {
if existing.UserId != userId {
return ordermodel.Order{}, errors.New("recovered order number already belongs to another user")
}
return existing, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return ordermodel.Order{}, err
}
paidAt := time.Now()
if item.PaidAt > 0 {
paidAt = time.Unix(item.PaidAt, 0)
}
quantity := item.Quantity
if quantity <= 0 {
quantity = 1
}
price := sub.UnitPrice * quantity
amount := item.OrderMoneyCents
if amount <= 0 {
amount = price
}
o := ordermodel.Order{
UserId: userId,
SubscriptionUserId: userId,
OrderNo: orderNo,
Type: 1,
Quantity: quantity,
Price: price,
Amount: amount,
Discount: price - amount,
PaymentId: 2,
Method: "EPay",
TradeNo: strings.TrimSpace(item.PaymentOrderNo),
Status: 5,
SubscribeId: item.SubscribeId,
IsNew: true,
CreatedAt: paidAt,
UpdatedAt: paidAt,
}
if o.Discount < 0 {
o.Discount = 0
}
if err := tx.Model(&ordermodel.Order{}).Create(&o).Error; err != nil {
return ordermodel.Order{}, err
}
return o, nil
}
func (l *RecoverOrderLogic) createOrExtendSubscribe(tx *gorm.DB, userId int64, orderId int64, item recoveryconfig.Order, sub *subscribemodel.Subscribe) (usermodel.Subscribe, error) {
now := time.Now()
var existing usermodel.Subscribe
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Model(&usermodel.Subscribe{}).
Where("user_id = ? AND subscribe_id = ?", userId, item.SubscribeId).
Order("expire_time DESC").
Order("id DESC").
First(&existing).Error
if err == nil {
base := now
if existing.ExpireTime.After(now) {
base = existing.ExpireTime
}
existing.OrderId = orderId
existing.ExpireTime = base.Add(time.Duration(item.Quantity) * 24 * time.Hour)
existing.Status = 1
existing.FinishedAt = nil
existing.NodeGroupId = sub.NodeGroupId
existing.Traffic = sub.Traffic
existing.Download = 0
existing.Upload = 0
if strings.TrimSpace(item.Note) != "" {
existing.Note = strings.TrimSpace(item.Note)
}
if err := tx.Model(&usermodel.Subscribe{}).Where("id = ?", existing.Id).Save(&existing).Error; err != nil {
return usermodel.Subscribe{}, err
}
return existing, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return usermodel.Subscribe{}, err
}
orderNo := item.OutOrderNo
if orderNo == "" {
orderNo = item.OrderNo
}
userSub := usermodel.Subscribe{
UserId: userId,
OrderId: orderId,
SubscribeId: item.SubscribeId,
NodeGroupId: sub.NodeGroupId,
StartTime: now,
ExpireTime: now.Add(time.Duration(item.Quantity) * 24 * time.Hour),
Traffic: sub.Traffic,
Download: 0,
Upload: 0,
Token: uuidx.SubscribeToken(orderNo),
UUID: uuid.New().String(),
Status: 1,
Note: strings.TrimSpace(item.Note),
}
if err := tx.Model(&usermodel.Subscribe{}).Create(&userSub).Error; err != nil {
return usermodel.Subscribe{}, err
}
return userSub, nil
}
func (l *RecoverOrderLogic) verifyEmailCode(email string, code string) error {
checker := commonLogic.NewCheckVerificationCodeLogic(l.ctx, l.svcCtx)
resp, err := checker.CheckVerificationCodeWithBehavior(&types.CheckVerificationCodeRequest{
Method: authmethod.Email,
Account: email,
Code: code,
Type: uint8(constant.Security),
}, commonLogic.VerifyCodeCheckBehavior{
Source: "order_recovery",
Consume: true,
AllowSceneFallback: true,
})
if err != nil {
return err
}
if resp == nil || !resp.Status {
return pkgerrors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), genericRecoveryError)
}
return nil
}
func (l *RecoverOrderLogic) findOrCreateUser(tx *gorm.DB, email string) (*usermodel.User, error) {
var auth usermodel.AuthMethods
if err := tx.Model(&usermodel.AuthMethods{}).
Where("auth_type = ? AND auth_identifier = ?", authmethod.Email, email).
First(&auth).Error; err == nil {
var u usermodel.User
if err := tx.Model(&usermodel.User{}).Where("id = ?", auth.UserId).First(&u).Error; err != nil {
return nil, err
}
u.AuthMethods = []usermodel.AuthMethods{auth}
return &u, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
u := &usermodel.User{
Password: tool.EncodePassWord(uuid.NewString()),
Algo: "default",
AuthMethods: []usermodel.AuthMethods{
{
AuthType: authmethod.Email,
AuthIdentifier: email,
Verified: true,
},
},
}
if err := tx.Create(u).Error; err != nil {
return nil, err
}
referCode := uuidx.UserInviteCode(u.Id)
if err := tx.Model(&usermodel.User{}).Where("id = ?", u.Id).Update("refer_code", referCode).Error; err != nil {
return nil, err
}
u.ReferCode = referCode
return u, nil
}
func (l *RecoverOrderLogic) clearCaches(userSub *usermodel.Subscribe, subscribeId int64, email string) {
if userSub != nil && userSub.Id > 0 {
if err := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, userSub); err != nil {
l.Errorw("[RecoverOrder] clear user subscribe cache failed", logger.Field("error", err.Error()))
}
if userSub.UserId > 0 {
if err := l.svcCtx.UserModel.ClearUserCache(l.ctx, &usermodel.User{
Id: userSub.UserId,
AuthMethods: []usermodel.AuthMethods{
{
UserId: userSub.UserId,
AuthType: authmethod.Email,
AuthIdentifier: email,
},
},
}); err != nil {
l.Errorw("[RecoverOrder] clear user cache failed", logger.Field("error", err.Error()))
}
}
}
if subscribeId > 0 {
if err := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeId); err != nil {
l.Errorw("[RecoverOrder] clear subscribe cache failed", logger.Field("error", err.Error()))
}
}
if l.svcCtx.NodeModel != nil {
if err := l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil {
l.Errorw("[RecoverOrder] clear server cache failed", logger.Field("error", err.Error()))
}
}
}
@@ -0,0 +1,103 @@
package recovery
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/hibiken/asynq"
"github.com/perfect-panel/server/internal/config"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
"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/limit"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/random"
"github.com/perfect-panel/server/pkg/xerr"
queue "github.com/perfect-panel/server/queue/types"
"github.com/pkg/errors"
)
type SendCodeLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewSendCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SendCodeLogic {
return &SendCodeLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *SendCodeLogic) SendCode(req *types.RecoverySendCodeRequest) (*types.SendCodeResponse, error) {
email := strings.ToLower(strings.TrimSpace(req.Email))
if email == "" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "email is required")
}
scene := constant.Security.String()
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email)
limiter := limit.NewPeriodLimit(60, 1, l.svcCtx.Redis, fmt.Sprintf("%s:%s:%s", config.SendIntervalKeyPrefix, "email", scene))
permit, err := limiter.Take(email)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Failed to take limit")
}
if !limiter.ParsePermitState(permit) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "send email too many requests")
}
permit, err = l.svcCtx.AuthLimiter.Take(fmt.Sprintf("%s:%s:%s", "email", scene, email))
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Failed to take limit")
}
if !l.svcCtx.AuthLimiter.ParsePermitState(permit) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TodaySendCountExceedsLimit), "send email too many requests")
}
code := random.Key(6, 0)
expireTime := l.svcCtx.Config.VerifyCode.VerifyCodeExpireTime
if expireTime == 0 {
expireTime = 900
}
val, _ := json.Marshal(commonLogic.CacheKeyPayload{
Code: code,
LastAt: time.Now().Unix(),
})
if err = l.svcCtx.Redis.Set(l.ctx, cacheKey, string(val), time.Second*time.Duration(expireTime)).Err(); err != nil {
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to set verification code")
}
expireMinutes := expireTime / 60
taskPayload := queue.SendEmailPayload{
Type: queue.EmailTypeVerify,
Scene: scene,
Email: email,
Subject: "Verification code",
Content: map[string]interface{}{
"Type": uint8(constant.Security),
"SiteLogo": l.svcCtx.Config.Site.SiteLogo,
"SiteName": l.svcCtx.Config.Site.SiteName,
"Expire": expireMinutes,
"Code": code,
},
}
payload, err := json.Marshal(taskPayload)
if err != nil {
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to marshal task payload")
}
task := asynq.NewTask(queue.ForthwithSendEmail, payload, asynq.MaxRetry(3))
if _, err = l.svcCtx.Queue.Enqueue(task); err != nil {
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to enqueue task")
}
resp := &types.SendCodeResponse{Status: true}
if l.svcCtx.Config.Model == constant.DevMode {
resp.Code = code
}
return resp, nil
}