feat: 对齐 Waffo 包年/永久结账与客户端席位 API

同步 OpsXShell 档位定价与 Waffo 商品映射,补齐 checkout/webhook/设备列表契约,便于桌面端购买与满席释放。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-12 11:42:56 +08:00
1 parent 4ae318335c
commit 95405de119
14 files changed
+958 -155

No files matched your search

+212 -26
View File
@@ -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