feat: 对齐 Waffo 包年/永久结账与客户端席位 API
同步 OpsXShell 档位定价与 Waffo 商品映射,补齐 checkout/webhook/设备列表契约,便于桌面端购买与满席释放。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
4ae318335c
commit
95405de119
14 files changed
+958
-155
No files matched your search
@@ -3,9 +3,11 @@ package order
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
"backend/internal/service/payment"
|
||||
"backend/utils"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -73,46 +75,91 @@ func (s *OrderService) CreateOrder(req dto.CreateOrderRequest, clientIP, userAge
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 校验产品与计划 // Logan.Li
|
||||
// 2. 解析产品与计划:优先 product_code / plan_type,否则要求 ProductID+PlanID // Logan.Li
|
||||
var product model.Product
|
||||
if err := s.db.First(&product, req.ProductID).Error; err != nil {
|
||||
return nil, errors.New("产品不存在")
|
||||
}
|
||||
var plan model.Plan
|
||||
if err := s.db.First(&plan, req.PlanID).Error; err != nil {
|
||||
return nil, errors.New("定价计划不存在")
|
||||
productCode := strings.TrimSpace(req.ProductCode)
|
||||
planType := strings.TrimSpace(req.PlanType)
|
||||
|
||||
if productCode != "" {
|
||||
if err := s.db.Where("code = ?", productCode).First(&product).Error; err != nil {
|
||||
return nil, errors.New("产品不存在")
|
||||
}
|
||||
if planType == "" {
|
||||
planType = "perpetual"
|
||||
}
|
||||
if err := s.db.Where("product_id = ? AND plan_type = ? AND status = ?", product.ID, planType, 1).
|
||||
Order("sort_order ASC").First(&plan).Error; err != nil {
|
||||
return nil, fmt.Errorf("未找到计划: product=%s plan_type=%s", productCode, planType)
|
||||
}
|
||||
} else {
|
||||
if req.ProductID == 0 || req.PlanID == 0 {
|
||||
return nil, errors.New("请提供 product_code/plan_type 或 product_id/plan_id")
|
||||
}
|
||||
if err := s.db.First(&product, req.ProductID).Error; err != nil {
|
||||
return nil, errors.New("产品不存在")
|
||||
}
|
||||
if err := s.db.First(&plan, req.PlanID).Error; err != nil {
|
||||
return nil, errors.New("定价计划不存在")
|
||||
}
|
||||
if plan.ProductID != product.ID {
|
||||
return nil, errors.New("定价计划与产品不匹配")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 防重复购买检查 // Logan.Li
|
||||
checkResult := s.CheckDuplicatePurchase(customer.ID, req.ProductID, plan.PlanType)
|
||||
checkResult := s.CheckDuplicatePurchase(customer.ID, product.ID, plan.PlanType)
|
||||
if !checkResult.AllowPurchase {
|
||||
return nil, fmt.Errorf("重复购买拦截: %s", checkResult.Reason)
|
||||
}
|
||||
|
||||
// 4. 创建订单 // Logan.Li
|
||||
order := model.Order{
|
||||
OrderNo: utils.GenerateOrderNo(),
|
||||
CustomerID: customer.ID,
|
||||
ProductID: req.ProductID,
|
||||
PlanID: req.PlanID,
|
||||
Amount: plan.Price,
|
||||
Currency: plan.Currency,
|
||||
Status: "pending",
|
||||
ClientIP: clientIP,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
if err := s.db.Create(&order).Error; err != nil {
|
||||
return nil, errors.New("创建订单失败")
|
||||
}
|
||||
|
||||
// 5. 选取可用支付渠道 // Logan.Li
|
||||
// 4. 选取可用支付渠道(先选网关,写入订单) // Logan.Li
|
||||
var gateway model.PaymentGatewayConfig
|
||||
if err := s.db.Where("enabled = ?", true).Order("priority ASC").First(&gateway).Error; err != nil {
|
||||
return nil, errors.New("暂无可用的支付渠道")
|
||||
}
|
||||
|
||||
// 6. 生成收银台地址(占位) // Logan.Li
|
||||
checkoutURL := fmt.Sprintf("https://checkout.%s.com/?order_no=%s", gateway.Gateway, order.OrderNo)
|
||||
notes := ""
|
||||
if deviceID := strings.TrimSpace(req.DeviceID); deviceID != "" {
|
||||
notes = fmt.Sprintf(`{"device_id":"%s"}`, deviceID)
|
||||
}
|
||||
|
||||
// 5. 创建订单 // Logan.Li
|
||||
order := model.Order{
|
||||
OrderNo: utils.GenerateOrderNo(),
|
||||
CustomerID: customer.ID,
|
||||
ProductID: product.ID,
|
||||
PlanID: plan.ID,
|
||||
Amount: plan.Price,
|
||||
Currency: plan.Currency,
|
||||
Status: "pending",
|
||||
PaymentGateway: gateway.Gateway,
|
||||
ClientIP: clientIP,
|
||||
UserAgent: userAgent,
|
||||
Notes: notes,
|
||||
}
|
||||
if err := s.db.Create(&order).Error; err != nil {
|
||||
return nil, errors.New("创建订单失败")
|
||||
}
|
||||
|
||||
// 6. 生成收银台地址 // Logan.Li
|
||||
var checkoutURL string
|
||||
if gateway.Gateway == "waffo" {
|
||||
url, err := payment.BuildCheckoutURL(
|
||||
payment.LoadWaffoConfig(),
|
||||
order.OrderNo,
|
||||
strings.TrimSpace(req.DeviceID),
|
||||
req.CustomerEmail,
|
||||
req.ReturnURL,
|
||||
plan.PlanType,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Waffo 收银台配置错误: %v", err)
|
||||
}
|
||||
checkoutURL = url
|
||||
} else {
|
||||
checkoutURL = fmt.Sprintf("https://checkout.%s.com/?order_no=%s", gateway.Gateway, order.OrderNo)
|
||||
}
|
||||
|
||||
return &dto.CreateOrderResponse{
|
||||
OrderNo: order.OrderNo,
|
||||
@@ -125,6 +172,145 @@ func (s *OrderService) CreateOrder(req dto.CreateOrderRequest, clientIP, userAge
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Checkout desktop-friendly entry: defaults product_code=opsxshell, plan_type=perpetual. // Logan.Li
|
||||
func (s *OrderService) Checkout(req dto.CheckoutRequest, clientIP, userAgent string) (*dto.CreateOrderResponse, error) {
|
||||
productCode := strings.TrimSpace(req.ProductCode)
|
||||
if productCode == "" {
|
||||
productCode = "opsxshell"
|
||||
}
|
||||
planType := strings.TrimSpace(req.PlanType)
|
||||
if planType == "" {
|
||||
planType = "perpetual"
|
||||
}
|
||||
return s.CreateOrder(dto.CreateOrderRequest{
|
||||
CustomerEmail: req.CustomerEmail,
|
||||
CustomerName: req.CustomerName,
|
||||
ProductCode: productCode,
|
||||
PlanType: planType,
|
||||
DeviceID: req.DeviceID,
|
||||
ReturnURL: req.ReturnURL,
|
||||
CancelURL: req.CancelURL,
|
||||
}, clientIP, userAgent)
|
||||
}
|
||||
|
||||
// ClaimPurchase fetch license after payment by order_no or email+product. // Logan.Li
|
||||
func (s *OrderService) ClaimPurchase(req dto.ClaimPurchaseRequest) (*dto.ClaimPurchaseResponse, error) {
|
||||
orderNo := strings.TrimSpace(req.OrderNo)
|
||||
email := strings.TrimSpace(req.CustomerEmail)
|
||||
productCode := strings.TrimSpace(req.ProductCode)
|
||||
if productCode == "" {
|
||||
productCode = "opsxshell"
|
||||
}
|
||||
|
||||
if orderNo != "" {
|
||||
var order model.Order
|
||||
if err := s.db.Where("order_no = ?", orderNo).First(&order).Error; err != nil {
|
||||
return nil, errors.New("订单不存在")
|
||||
}
|
||||
switch order.Status {
|
||||
case "pending":
|
||||
return &dto.ClaimPurchaseResponse{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: "pending",
|
||||
Message: "订单尚未支付完成,请稍后再试",
|
||||
}, nil
|
||||
case "paid":
|
||||
licenseKey := ""
|
||||
if order.LicenseID > 0 {
|
||||
var lic model.License
|
||||
if err := s.db.First(&lic, order.LicenseID).Error; err == nil {
|
||||
licenseKey = lic.LicenseKey
|
||||
}
|
||||
}
|
||||
if licenseKey == "" {
|
||||
var ent model.CustomerEntitlement
|
||||
if err := s.db.Where("customer_id = ? AND product_id = ? AND status = ?", order.CustomerID, order.ProductID, "active").
|
||||
First(&ent).Error; err == nil {
|
||||
licenseKey = ent.LicenseKey
|
||||
}
|
||||
}
|
||||
if licenseKey == "" {
|
||||
return &dto.ClaimPurchaseResponse{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: "paid",
|
||||
Message: "订单已支付,但 License 尚未就绪",
|
||||
}, nil
|
||||
}
|
||||
return &dto.ClaimPurchaseResponse{
|
||||
LicenseKey: licenseKey,
|
||||
OrderNo: order.OrderNo,
|
||||
Status: "paid",
|
||||
Message: "ok",
|
||||
}, nil
|
||||
default:
|
||||
return &dto.ClaimPurchaseResponse{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: order.Status,
|
||||
Message: fmt.Sprintf("订单状态为 %s,无法领取授权", order.Status),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
return nil, errors.New("请提供 order_no 或 customer_email")
|
||||
}
|
||||
|
||||
var product model.Product
|
||||
if err := s.db.Where("code = ?", productCode).First(&product).Error; err != nil {
|
||||
return nil, errors.New("产品不存在")
|
||||
}
|
||||
|
||||
var customer model.Customer
|
||||
if err := s.db.Where("email = ?", email).First(&customer).Error; err != nil {
|
||||
return &dto.ClaimPurchaseResponse{
|
||||
Status: "not_found",
|
||||
Message: "未找到购买记录",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var ent model.CustomerEntitlement
|
||||
if err := s.db.Where("customer_id = ? AND product_id = ?", customer.ID, product.ID).
|
||||
Order("updated_at DESC").First(&ent).Error; err != nil {
|
||||
// 可能有未支付订单 // Logan.Li
|
||||
var pending model.Order
|
||||
if err2 := s.db.Where("customer_id = ? AND product_id = ? AND status = ?", customer.ID, product.ID, "pending").
|
||||
Order("created_at DESC").First(&pending).Error; err2 == nil {
|
||||
return &dto.ClaimPurchaseResponse{
|
||||
OrderNo: pending.OrderNo,
|
||||
Status: "pending",
|
||||
Message: "订单尚未支付完成,请稍后再试",
|
||||
}, nil
|
||||
}
|
||||
return &dto.ClaimPurchaseResponse{
|
||||
Status: "not_found",
|
||||
Message: "未找到购买记录",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if ent.Status == "active" && ent.LicenseKey != "" {
|
||||
return &dto.ClaimPurchaseResponse{
|
||||
LicenseKey: ent.LicenseKey,
|
||||
Status: "paid",
|
||||
Message: "ok",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var pending model.Order
|
||||
if err := s.db.Where("customer_id = ? AND product_id = ? AND status = ?", customer.ID, product.ID, "pending").
|
||||
Order("created_at DESC").First(&pending).Error; err == nil {
|
||||
return &dto.ClaimPurchaseResponse{
|
||||
OrderNo: pending.OrderNo,
|
||||
Status: "pending",
|
||||
Message: "订单尚未支付完成,请稍后再试",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &dto.ClaimPurchaseResponse{
|
||||
Status: ent.Status,
|
||||
Message: "暂无可用授权",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PaymentWebhook 支付回调(幂等 + 事务) // Logan.Li
|
||||
func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP string) error {
|
||||
// 幂等检查 // Logan.Li
|
||||
|
||||
Reference in new issue
Block a user