This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
syntax = "v1"
|
||||||
|
|
||||||
|
info (
|
||||||
|
title: "recovery API"
|
||||||
|
desc: "API for order recovery"
|
||||||
|
author: "Tension"
|
||||||
|
email: "tension@ppanel.com"
|
||||||
|
version: "0.0.1"
|
||||||
|
)
|
||||||
|
|
||||||
|
import "../types.api"
|
||||||
|
|
||||||
|
type (
|
||||||
|
RecoverySendCodeRequest {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
}
|
||||||
|
RecoverOrderRequest {
|
||||||
|
OrderNo string `json:"order_no" validate:"required"`
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Code string `json:"code" validate:"required"`
|
||||||
|
}
|
||||||
|
RecoverOrderResponse {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@server (
|
||||||
|
prefix: v1/public/recovery
|
||||||
|
group: public/recovery
|
||||||
|
middleware: DeviceMiddleware
|
||||||
|
)
|
||||||
|
service ppanel {
|
||||||
|
@doc "Send recovery verification code"
|
||||||
|
@handler SendCode
|
||||||
|
post /send_code (RecoverySendCodeRequest) returns (SendCodeResponse)
|
||||||
|
|
||||||
|
@doc "Recover order subscription"
|
||||||
|
@handler RecoverOrder
|
||||||
|
post /order (RecoverOrderRequest) returns (RecoverOrderResponse)
|
||||||
|
}
|
||||||
+1
-1
@@ -15,7 +15,7 @@ Logger: # 日志配置
|
|||||||
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
||||||
|
|
||||||
MySQL:
|
MySQL:
|
||||||
Addr: 103.150.215.44:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
||||||
Username: root # MySQL用户名
|
Username: root # MySQL用户名
|
||||||
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
|
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
|
||||||
Dbname: hifast # MySQL数据库名
|
Dbname: hifast # MySQL数据库名
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS `order_recovery_claims`;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `order_recovery_claims` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
`order_no` VARCHAR(255) NOT NULL COMMENT 'Recovered Order No',
|
||||||
|
`email` VARCHAR(255) NOT NULL COMMENT 'Claim Email',
|
||||||
|
`user_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'User ID',
|
||||||
|
`subscribe_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'Subscribe ID',
|
||||||
|
`order_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'Recovered Order ID',
|
||||||
|
`user_subscribe_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'User Subscribe ID',
|
||||||
|
`claimed_at` DATETIME(3) NOT NULL COMMENT 'Claimed Time',
|
||||||
|
`created_at` DATETIME(3) DEFAULT NULL COMMENT 'Creation Time',
|
||||||
|
`updated_at` DATETIME(3) DEFAULT NULL COMMENT 'Update Time',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `idx_order_recovery_claim_order_no` (`order_no`),
|
||||||
|
KEY `idx_order_recovery_claim_email` (`email`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/public/recovery"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RecoverOrderHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.RecoverOrderRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
validateErr := svcCtx.Validate(&req)
|
||||||
|
if validateErr != nil {
|
||||||
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := recovery.NewRecoverOrderLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.RecoverOrder(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/public/recovery"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SendCodeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.RecoverySendCodeRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
validateErr := svcCtx.Validate(&req)
|
||||||
|
if validateErr != nil {
|
||||||
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := recovery.NewSendCodeLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.SendCode(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import (
|
|||||||
publicOrder "github.com/perfect-panel/server/internal/handler/public/order"
|
publicOrder "github.com/perfect-panel/server/internal/handler/public/order"
|
||||||
publicPayment "github.com/perfect-panel/server/internal/handler/public/payment"
|
publicPayment "github.com/perfect-panel/server/internal/handler/public/payment"
|
||||||
publicPortal "github.com/perfect-panel/server/internal/handler/public/portal"
|
publicPortal "github.com/perfect-panel/server/internal/handler/public/portal"
|
||||||
|
publicRecovery "github.com/perfect-panel/server/internal/handler/public/recovery"
|
||||||
publicRedemption "github.com/perfect-panel/server/internal/handler/public/redemption"
|
publicRedemption "github.com/perfect-panel/server/internal/handler/public/redemption"
|
||||||
publicSubscribe "github.com/perfect-panel/server/internal/handler/public/subscribe"
|
publicSubscribe "github.com/perfect-panel/server/internal/handler/public/subscribe"
|
||||||
publicTicket "github.com/perfect-panel/server/internal/handler/public/ticket"
|
publicTicket "github.com/perfect-panel/server/internal/handler/public/ticket"
|
||||||
@@ -932,6 +933,17 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
publicRedemptionGroupRouter.POST("/", publicRedemption.RedeemCodeHandler(serverCtx))
|
publicRedemptionGroupRouter.POST("/", publicRedemption.RedeemCodeHandler(serverCtx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publicRecoveryGroupRouter := router.Group("/v1/public/recovery")
|
||||||
|
publicRecoveryGroupRouter.Use(middleware.DeviceMiddleware(serverCtx))
|
||||||
|
|
||||||
|
{
|
||||||
|
// Send recovery verification code
|
||||||
|
publicRecoveryGroupRouter.POST("/send_code", publicRecovery.SendCodeHandler(serverCtx))
|
||||||
|
|
||||||
|
// Recover order subscription
|
||||||
|
publicRecoveryGroupRouter.POST("/order", publicRecovery.RecoverOrderHandler(serverCtx))
|
||||||
|
}
|
||||||
|
|
||||||
publicSubscribeGroupRouter := router.Group("/v1/public/subscribe")
|
publicSubscribeGroupRouter := router.Group("/v1/public/subscribe")
|
||||||
publicSubscribeGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
publicSubscribeGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type OrderRecoveryClaim struct {
|
||||||
|
Id int64 `gorm:"primaryKey"`
|
||||||
|
OrderNo string `gorm:"type:varchar(255);not null;uniqueIndex:idx_order_recovery_claim_order_no;comment:Recovered Order No"`
|
||||||
|
Email string `gorm:"type:varchar(255);not null;index:idx_order_recovery_claim_email;comment:Claim Email"`
|
||||||
|
UserId int64 `gorm:"type:bigint;not null;default:0;comment:User ID"`
|
||||||
|
SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe ID"`
|
||||||
|
OrderId int64 `gorm:"type:bigint;not null;default:0;comment:Recovered Order ID"`
|
||||||
|
UserSubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:User Subscribe ID"`
|
||||||
|
ClaimedAt time.Time `gorm:"comment:Claimed Time"`
|
||||||
|
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (OrderRecoveryClaim) TableName() string {
|
||||||
|
return "order_recovery_claims"
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed recovery_orders.json
|
||||||
|
var recoveryOrdersJSON []byte
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Orders []Order `json:"orders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Order struct {
|
||||||
|
OutOrderNo string `json:"out_order_no,omitempty"`
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
OrderStatus string `json:"order_status,omitempty"`
|
||||||
|
SubscribeId int64 `json:"subscribe_id"`
|
||||||
|
LegacyID string `json:"订阅id,omitempty"`
|
||||||
|
ExpireAt int64 `json:"expire_at"`
|
||||||
|
Quantity int64 `json:"quantity,omitempty"`
|
||||||
|
PaidAt int64 `json:"paid_at,omitempty"`
|
||||||
|
PaymentOrderNo string `json:"payment_order_no,omitempty"`
|
||||||
|
OrderMoneyCents int64 `json:"order_money_cents,omitempty"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
loadOnce sync.Once
|
||||||
|
orders map[string]Order
|
||||||
|
loadErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
func FindOrder(orderNo string) (Order, bool, error) {
|
||||||
|
loadOnce.Do(func() {
|
||||||
|
orders, loadErr = loadOrders()
|
||||||
|
})
|
||||||
|
if loadErr != nil {
|
||||||
|
return Order{}, false, loadErr
|
||||||
|
}
|
||||||
|
item, ok := orders[strings.TrimSpace(orderNo)]
|
||||||
|
return item, ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrders() (map[string]Order, error) {
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(recoveryOrdersJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse recovery orders json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]Order, len(cfg.Orders))
|
||||||
|
for i, item := range cfg.Orders {
|
||||||
|
item.OutOrderNo = strings.TrimSpace(item.OutOrderNo)
|
||||||
|
item.OrderNo = strings.TrimSpace(item.OrderNo)
|
||||||
|
item.OrderStatus = strings.ToLower(strings.TrimSpace(item.OrderStatus))
|
||||||
|
if item.OutOrderNo == "" {
|
||||||
|
item.OutOrderNo = item.OrderNo
|
||||||
|
}
|
||||||
|
if item.OrderNo == "" {
|
||||||
|
item.OrderNo = item.OutOrderNo
|
||||||
|
}
|
||||||
|
if item.OrderStatus == "" {
|
||||||
|
item.OrderStatus = "success"
|
||||||
|
}
|
||||||
|
if item.SubscribeId == 0 && strings.TrimSpace(item.LegacyID) != "" {
|
||||||
|
id, err := strconv.ParseInt(strings.TrimSpace(item.LegacyID), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("orders[%d] legacy subscribe id is invalid", i)
|
||||||
|
}
|
||||||
|
item.SubscribeId = id
|
||||||
|
}
|
||||||
|
if item.OutOrderNo == "" {
|
||||||
|
return nil, fmt.Errorf("orders[%d] out_order_no is required", i)
|
||||||
|
}
|
||||||
|
if item.SubscribeId <= 0 {
|
||||||
|
return nil, fmt.Errorf("orders[%d] subscribe_id is required", i)
|
||||||
|
}
|
||||||
|
if item.ExpireAt <= 0 {
|
||||||
|
return nil, fmt.Errorf("orders[%d] expire_at is required", i)
|
||||||
|
}
|
||||||
|
if item.Quantity <= 0 {
|
||||||
|
item.Quantity = quantityFromAmount(item.OrderMoneyCents)
|
||||||
|
}
|
||||||
|
if item.Quantity <= 0 {
|
||||||
|
return nil, fmt.Errorf("orders[%d] quantity is required", i)
|
||||||
|
}
|
||||||
|
if item.PaidAt < 0 {
|
||||||
|
return nil, fmt.Errorf("orders[%d] paid_at is invalid", i)
|
||||||
|
}
|
||||||
|
if item.OrderMoneyCents < 0 {
|
||||||
|
return nil, fmt.Errorf("orders[%d] order_money_cents is invalid", i)
|
||||||
|
}
|
||||||
|
if _, exists := result[item.OutOrderNo]; exists {
|
||||||
|
return nil, fmt.Errorf("duplicate recovery out_order_no %s", item.OutOrderNo)
|
||||||
|
}
|
||||||
|
result[item.OutOrderNo] = item
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func quantityFromAmount(amount int64) int64 {
|
||||||
|
switch amount {
|
||||||
|
case 1953:
|
||||||
|
return 7
|
||||||
|
case 4193:
|
||||||
|
return 30
|
||||||
|
case 9093:
|
||||||
|
return 90
|
||||||
|
case 31500:
|
||||||
|
return 365
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,7 @@ func initServer(svc *svc.ServiceContext) *gin.Engine {
|
|||||||
|
|
||||||
// register handlers
|
// register handlers
|
||||||
handler.RegisterHandlers(r, svc)
|
handler.RegisterHandlers(r, svc)
|
||||||
|
r.StaticFile("/order-recovery.html", "./public/order-recovery.html")
|
||||||
// register subscribe handler
|
// register subscribe handler
|
||||||
handler.RegisterSubscribeHandlers(r, svc)
|
handler.RegisterSubscribeHandlers(r, svc)
|
||||||
// register telegram handler
|
// register telegram handler
|
||||||
|
|||||||
@@ -2235,6 +2235,17 @@ type RechargeOrderResponse struct {
|
|||||||
OrderNo string `json:"order_no"`
|
OrderNo string `json:"order_no"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecoverOrderRequest struct {
|
||||||
|
OrderNo string `json:"order_no" validate:"required"`
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Code string `json:"code" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecoverOrderResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
type RedeemCodeRequest struct {
|
type RedeemCodeRequest struct {
|
||||||
Code string `json:"code" validate:"required"`
|
Code string `json:"code" validate:"required"`
|
||||||
}
|
}
|
||||||
@@ -2359,6 +2370,10 @@ type ResetTrafficOrderResponse struct {
|
|||||||
OrderNo string `json:"order_no"`
|
OrderNo string `json:"order_no"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecoverySendCodeRequest struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
}
|
||||||
|
|
||||||
type ResetUserSubscribeTokenRequest struct {
|
type ResetUserSubscribeTokenRequest struct {
|
||||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -34,6 +34,7 @@ import (
|
|||||||
"apis/public/user.api"
|
"apis/public/user.api"
|
||||||
"apis/public/subscribe.api"
|
"apis/public/subscribe.api"
|
||||||
"apis/public/redemption.api"
|
"apis/public/redemption.api"
|
||||||
|
"apis/public/recovery.api"
|
||||||
"apis/public/order.api"
|
"apis/public/order.api"
|
||||||
"apis/public/announcement.api"
|
"apis/public/announcement.api"
|
||||||
"apis/public/ticket.api"
|
"apis/public/ticket.api"
|
||||||
@@ -42,4 +43,3 @@ import (
|
|||||||
"apis/public/portal.api"
|
"apis/public/portal.api"
|
||||||
"apis/public/iap.api"
|
"apis/public/iap.api"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<meta name="format-detection" content="telephone=no,email=no" />
|
||||||
|
<title>订单订阅恢复</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f5f7fb;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #162033;
|
||||||
|
--muted: #667085;
|
||||||
|
--line: #d8dee9;
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-pressed: #1d4ed8;
|
||||||
|
--danger: #d92d20;
|
||||||
|
--success: #047857;
|
||||||
|
--shadow: 0 18px 48px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% -10%, rgba(37, 99, 235, 0.16), transparent 34rem),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||||
|
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: max(20px, env(safe-area-inset-top)) 16px
|
||||||
|
max(24px, env(safe-area-inset-bottom));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(100%, 440px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid rgba(216, 222, 233, 0.86);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
height: 46px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 13px;
|
||||||
|
color: var(--text);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 116px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
height: 46px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 0.12s ease,
|
||||||
|
background 0.12s ease,
|
||||||
|
opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:not(:disabled):hover {
|
||||||
|
background: var(--primary-pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #e9eefb;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: none;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
border: 1px solid rgba(4, 120, 87, 0.24);
|
||||||
|
background: rgba(4, 120, 87, 0.08);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
border: 1px solid rgba(217, 45, 32, 0.24);
|
||||||
|
background: rgba(217, 45, 32, 0.08);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.page {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<section class="shell" aria-labelledby="page-title">
|
||||||
|
<div class="brand">
|
||||||
|
<h1 id="page-title">订单订阅恢复</h1>
|
||||||
|
<p>填写订单号并验证邮箱,系统会为该邮箱恢复对应订阅。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="card" id="recovery-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="order-no">订单号</label>
|
||||||
|
<input
|
||||||
|
id="order-no"
|
||||||
|
name="order_no"
|
||||||
|
autocomplete="off"
|
||||||
|
inputmode="text"
|
||||||
|
placeholder="请输入订单号"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">邮箱</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
autocomplete="email"
|
||||||
|
inputmode="email"
|
||||||
|
placeholder="请输入接收验证码的邮箱"
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="code">邮箱验证码</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<input
|
||||||
|
id="code"
|
||||||
|
name="code"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="8"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button class="btn-secondary" id="send-code" type="button">
|
||||||
|
获取验证码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-primary" id="submit-btn" type="submit">
|
||||||
|
恢复订阅
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="status" id="status" role="status"></div>
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
恢复成功后,请使用该邮箱验证码登录账号查看订阅。每个订单号只能恢复一次。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById("recovery-form");
|
||||||
|
const sendCodeButton = document.getElementById("send-code");
|
||||||
|
const submitButton = document.getElementById("submit-btn");
|
||||||
|
const statusBox = document.getElementById("status");
|
||||||
|
const emailInput = document.getElementById("email");
|
||||||
|
const orderInput = document.getElementById("order-no");
|
||||||
|
const codeInput = document.getElementById("code");
|
||||||
|
|
||||||
|
let countdownTimer = null;
|
||||||
|
|
||||||
|
function showStatus(type, message) {
|
||||||
|
statusBox.className = `status show ${type}`;
|
||||||
|
statusBox.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStatus() {
|
||||||
|
statusBox.className = "status";
|
||||||
|
statusBox.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmail() {
|
||||||
|
emailInput.value = emailInput.value.trim().toLowerCase();
|
||||||
|
return emailInput.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson(path, body) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || payload.code !== 200) {
|
||||||
|
throw new Error(payload.msg || "请求失败,请稍后再试");
|
||||||
|
}
|
||||||
|
return payload.data || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown(seconds) {
|
||||||
|
let remaining = seconds;
|
||||||
|
sendCodeButton.disabled = true;
|
||||||
|
sendCodeButton.textContent = `${remaining}s`;
|
||||||
|
clearInterval(countdownTimer);
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(countdownTimer);
|
||||||
|
sendCodeButton.disabled = false;
|
||||||
|
sendCodeButton.textContent = "获取验证码";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendCodeButton.textContent = `${remaining}s`;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCodeButton.addEventListener("click", async () => {
|
||||||
|
clearStatus();
|
||||||
|
const email = normalizeEmail();
|
||||||
|
if (!emailInput.reportValidity()) return;
|
||||||
|
|
||||||
|
sendCodeButton.disabled = true;
|
||||||
|
sendCodeButton.textContent = "发送中";
|
||||||
|
try {
|
||||||
|
await postJson("/v1/public/recovery/send_code", { email });
|
||||||
|
showStatus("success", "验证码已发送,请查看邮箱。");
|
||||||
|
startCountdown(60);
|
||||||
|
} catch (error) {
|
||||||
|
sendCodeButton.disabled = false;
|
||||||
|
sendCodeButton.textContent = "获取验证码";
|
||||||
|
showStatus("error", error.message || "验证码发送失败,请稍后再试。");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
clearStatus();
|
||||||
|
const orderNo = orderInput.value.trim();
|
||||||
|
const email = normalizeEmail();
|
||||||
|
const code = codeInput.value.trim();
|
||||||
|
if (!form.reportValidity()) return;
|
||||||
|
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.textContent = "恢复中";
|
||||||
|
try {
|
||||||
|
const result = await postJson("/v1/public/recovery/order", {
|
||||||
|
order_no: orderNo,
|
||||||
|
email,
|
||||||
|
code
|
||||||
|
});
|
||||||
|
showStatus(
|
||||||
|
"success",
|
||||||
|
result.message || "恢复完成,请使用该邮箱验证码登录查看订阅。"
|
||||||
|
);
|
||||||
|
form.reset();
|
||||||
|
} catch (error) {
|
||||||
|
showStatus("error", "订单或邮箱验证码无效,请确认后重试。");
|
||||||
|
} finally {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
submitButton.textContent = "恢复订阅";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type remoteOrder struct {
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
OutOrderNo string `json:"out_order_no"`
|
||||||
|
OrderMoney json.Number `json:"order_money"`
|
||||||
|
OrderStatus string `json:"order_status"`
|
||||||
|
PaymentTime *int64 `json:"payment_time"`
|
||||||
|
CreateTime int64 `json:"create_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type recoveryFile struct {
|
||||||
|
Orders []recoveryOrder `json:"orders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type recoveryOrder struct {
|
||||||
|
OutOrderNo string `json:"out_order_no"`
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
OrderStatus string `json:"order_status"`
|
||||||
|
SubscribeId int64 `json:"subscribe_id"`
|
||||||
|
ExpireAt int64 `json:"expire_at"`
|
||||||
|
Quantity int64 `json:"quantity"`
|
||||||
|
PaidAt int64 `json:"paid_at,omitempty"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
PaymentOrderNo string `json:"payment_order_no,omitempty"`
|
||||||
|
OrderMoneyCents int64 `json:"order_money_cents,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminOrderResponse struct {
|
||||||
|
Code uint32 `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
List []adminOrder `json:"list"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminOrder struct {
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
input string
|
||||||
|
output string
|
||||||
|
adminOrders string
|
||||||
|
subscribeID int64
|
||||||
|
onlyMissing bool
|
||||||
|
)
|
||||||
|
flag.StringVar(&input, "in", "aaa.json", "input payment platform orders JSON")
|
||||||
|
flag.StringVar(&output, "out", "internal/recovery/recovery_orders.json", "output recovery orders JSON")
|
||||||
|
flag.StringVar(&adminOrders, "admin-orders", "", "optional admin order list response JSON; when provided, matched out_order_no values are skipped")
|
||||||
|
flag.Int64Var(&subscribeID, "subscribe-id", 1, "subscribe id to use for recovered subscriptions")
|
||||||
|
flag.BoolVar(&onlyMissing, "only-missing", true, "only output payment success orders missing from admin order list when -admin-orders is set")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
orders, err := readOrders(input)
|
||||||
|
must(err)
|
||||||
|
existingOrders := map[string]struct{}{}
|
||||||
|
if adminOrders != "" {
|
||||||
|
existingOrders, err = readAdminOrderSet(adminOrders)
|
||||||
|
must(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
daysByAmount := map[int64]int64{
|
||||||
|
1953: 7,
|
||||||
|
4193: 30,
|
||||||
|
9093: 90,
|
||||||
|
31500: 365,
|
||||||
|
}
|
||||||
|
|
||||||
|
out := recoveryFile{Orders: make([]recoveryOrder, 0, len(orders))}
|
||||||
|
var skipped []string
|
||||||
|
var matchedExisting int
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, item := range orders {
|
||||||
|
if strings.ToLower(strings.TrimSpace(item.OrderStatus)) != "success" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
localOrderNo := strings.TrimSpace(item.OutOrderNo)
|
||||||
|
if localOrderNo == "" {
|
||||||
|
skipped = append(skipped, fmt.Sprintf("%s missing out_order_no", item.OrderNo))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[localOrderNo]; ok {
|
||||||
|
skipped = append(skipped, fmt.Sprintf("%s duplicate out_order_no", localOrderNo))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := existingOrders[localOrderNo]; exists && onlyMissing {
|
||||||
|
matchedExisting++
|
||||||
|
seen[localOrderNo] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
amount := decimalYuanToCents(item.OrderMoney)
|
||||||
|
days, ok := daysByAmount[amount]
|
||||||
|
if !ok {
|
||||||
|
skipped = append(skipped, fmt.Sprintf("%s unknown amount %s", localOrderNo, item.OrderMoney.String()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := item.CreateTime
|
||||||
|
if item.PaymentTime != nil && *item.PaymentTime > 0 {
|
||||||
|
base = *item.PaymentTime
|
||||||
|
}
|
||||||
|
if base <= 0 {
|
||||||
|
skipped = append(skipped, fmt.Sprintf("%s missing payment/create time", localOrderNo))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[localOrderNo] = struct{}{}
|
||||||
|
out.Orders = append(out.Orders, recoveryOrder{
|
||||||
|
OutOrderNo: localOrderNo,
|
||||||
|
OrderNo: strings.TrimSpace(item.OrderNo),
|
||||||
|
OrderStatus: "success",
|
||||||
|
SubscribeId: subscribeID,
|
||||||
|
ExpireAt: time.Unix(base, 0).Add(time.Duration(days) * 24 * time.Hour).UnixMilli(),
|
||||||
|
Quantity: days,
|
||||||
|
PaidAt: base,
|
||||||
|
Note: "data recovery",
|
||||||
|
PaymentOrderNo: strings.TrimSpace(item.OrderNo),
|
||||||
|
OrderMoneyCents: amount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(out.Orders, func(i, j int) bool {
|
||||||
|
return out.Orders[i].OutOrderNo < out.Orders[j].OutOrderNo
|
||||||
|
})
|
||||||
|
|
||||||
|
must(writeJSON(output, out))
|
||||||
|
fmt.Printf("converted recovery orders=%d matched_existing=%d skipped=%d output=%s\n", len(out.Orders), matchedExisting, len(skipped), output)
|
||||||
|
for _, msg := range skipped {
|
||||||
|
fmt.Printf("skip: %s\n", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAdminOrderSet(path string) (map[string]struct{}, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var resp adminOrderResponse
|
||||||
|
if err := json.Unmarshal(data, &resp); err == nil && resp.Data.List != nil {
|
||||||
|
if resp.Code != 0 && resp.Code != 200 {
|
||||||
|
return nil, fmt.Errorf("admin order response failed: code=%d msg=%s", resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
|
return adminOrdersToSet(resp.Data.List), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []adminOrder
|
||||||
|
if err := json.Unmarshal(data, &list); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return adminOrdersToSet(list), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminOrdersToSet(list []adminOrder) map[string]struct{} {
|
||||||
|
set := make(map[string]struct{}, len(list))
|
||||||
|
for _, item := range list {
|
||||||
|
orderNo := strings.TrimSpace(item.OrderNo)
|
||||||
|
if orderNo != "" {
|
||||||
|
set[orderNo] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
func readOrders(path string) ([]remoteOrder, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var orders []remoteOrder
|
||||||
|
if err := json.Unmarshal(data, &orders); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return orders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(path string, data recoveryFile) error {
|
||||||
|
bytes, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bytes = append(bytes, '\n')
|
||||||
|
return os.WriteFile(path, bytes, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decimalYuanToCents(n json.Number) int64 {
|
||||||
|
s := strings.TrimSpace(n.String())
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(s, ".", 2)
|
||||||
|
yuan, _ := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
cents := yuan * 100
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return cents
|
||||||
|
}
|
||||||
|
frac := parts[1]
|
||||||
|
if len(frac) > 2 {
|
||||||
|
frac = frac[:2]
|
||||||
|
}
|
||||||
|
for len(frac) < 2 {
|
||||||
|
frac += "0"
|
||||||
|
}
|
||||||
|
f, _ := strconv.ParseInt(frac, 10, 64)
|
||||||
|
if strings.HasPrefix(parts[0], "-") {
|
||||||
|
return cents - f
|
||||||
|
}
|
||||||
|
return cents + f
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(err error) {
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user