Files
opsx-licence/backend/internal/service/order/order_service.go
T
Logan.LiandCursor 8cc2c83f9d feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强
支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 20:25:46 +08:00

674 lines
21 KiB
Go

package order
import (
"backend/internal/dto"
"backend/internal/model"
"backend/internal/service/payment"
"backend/utils"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
// OrderService 订单业务逻辑层 // Logan.Li
type OrderService struct {
db *gorm.DB
}
// NewOrderService 创建 service 实例 // Logan.Li
func NewOrderService(db *gorm.DB) *OrderService {
return &OrderService{db: db}
}
// List 分页查询订单 // Logan.Li
func (s *OrderService) List(page, pageSize int, status string, productID uint) ([]dto.OrderResponse, int64, error) {
var orders []model.Order
var total int64
query := s.db.Model(&model.Order{}).Preload("Customer").Preload("Product").Preload("Plan")
if status != "" {
query = query.Where("status = ?", status)
}
if productID > 0 {
query = query.Where("product_id = ?", productID)
}
query.Count(&total)
if err := query.Order("created_at DESC").
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&orders).Error; err != nil {
return nil, 0, err
}
results := make([]dto.OrderResponse, 0, len(orders))
for _, o := range orders {
results = append(results, toOrderResponse(o))
}
return results, total, nil
}
// GetByID 获取订单详情 // Logan.Li
func (s *OrderService) GetByID(id uint) (*dto.OrderResponse, error) {
var order model.Order
if err := s.db.Preload("Customer").Preload("Product").Preload("Plan").First(&order, id).Error; err != nil {
return nil, errors.New("订单不存在")
}
resp := toOrderResponse(order)
return &resp, nil
}
// CreateOrder 客户端下单(含防重复购买检查) // Logan.Li
func (s *OrderService) CreateOrder(req dto.CreateOrderRequest, clientIP, userAgent string) (*dto.CreateOrderResponse, error) {
// 1. 查找或创建客户 // Logan.Li
var customer model.Customer
if err := s.db.Where("email = ?", req.CustomerEmail).First(&customer).Error; err != nil {
name := req.CustomerName
if name == "" {
name = req.CustomerEmail
}
customer = model.Customer{Name: name, Email: req.CustomerEmail, Status: 1}
if err := s.db.Create(&customer).Error; err != nil {
return nil, errors.New("创建客户失败")
}
}
// 2. 解析产品与计划:优先 product_code / plan_type,否则要求 ProductID+PlanID // Logan.Li
var product model.Product
var plan model.Plan
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, product.ID, plan.PlanType)
if !checkResult.AllowPurchase {
return nil, fmt.Errorf("重复购买拦截: %s", checkResult.Reason)
}
// 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("暂无可用的支付渠道")
}
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(s.db),
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,
CheckoutURL: checkoutURL,
Amount: plan.Price,
Currency: plan.Currency,
Status: "pending",
Gateway: gateway.Gateway,
ExpiresAt: time.Now().Add(30 * time.Minute).Format("2006-01-02 15:04:05"),
}, 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
}
// resolveWebhookOrder finds local order by merchant external id, Waffo order id, or buyer email. // Logan.Li
func (s *OrderService) resolveWebhookOrder(req dto.PaymentWebhookRequest) (model.Order, error) {
var order model.Order
if no := strings.TrimSpace(req.OrderNo); no != "" {
if err := s.db.Where("order_no = ?", no).First(&order).Error; err == nil {
return order, nil
}
// Hosted-store mistaken: Waffo ORD_ put in OrderNo — try gateway column. // Logan.Li
if err := s.db.Where("gateway_order_no = ?", no).First(&order).Error; err == nil {
return order, nil
}
}
if g := strings.TrimSpace(req.GatewayOrderNo); g != "" {
if err := s.db.Where("gateway_order_no = ?", g).First(&order).Error; err == nil {
return order, nil
}
}
if email := strings.TrimSpace(req.CustomerEmail); email != "" {
var customer model.Customer
if err := s.db.Where("email = ?", email).First(&customer).Error; err == nil {
if err := s.db.Where("customer_id = ? AND status = ?", customer.ID, "pending").
Order("created_at DESC").First(&order).Error; err == nil {
return order, nil
}
}
}
return order, errors.New("订单不存在")
}
// PaymentWebhook 支付回调(幂等 + 事务) // Logan.Li
func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP string) error {
order, err := s.resolveWebhookOrder(req)
if err != nil {
return err
}
if order.Status == "paid" || order.Status == "refunded" {
return nil // 已处理,幂等返回
}
// 事务处理 // Logan.Li
return s.db.Transaction(func(tx *gorm.DB) error {
now := time.Now()
switch req.Status {
case "paid", "completed":
// 更新订单 // Logan.Li
order.Status = "paid"
order.GatewayOrderNo = req.GatewayOrderNo
order.PaymentGateway = req.Gateway
order.PaymentMethod = req.Gateway
order.PaidAt = &now
if err := tx.Save(&order).Error; err != nil {
return err
}
// 发放 License // Logan.Li
var plan model.Plan
tx.First(&plan, order.PlanID)
var validUntil *time.Time
var endDate time.Time
if plan.Duration == 0 {
endDate = model.PermanentEndDate
} else {
endDate = now.AddDate(0, 0, plan.Duration)
validUntil = &endDate
}
lic := model.License{
LicenseKey: utils.GenerateLicenseKey(),
CustomerID: order.CustomerID,
ProductID: order.ProductID,
Status: model.LicenseStatusActive,
StartDate: now,
EndDate: endDate,
Duration: plan.Duration,
MaxActivations: plan.MaxDevices,
}
if err := tx.Create(&lic).Error; err != nil {
return err
}
order.LicenseID = lic.ID
tx.Save(&order)
// 更新权益记录(防重复购买核心) // Logan.Li
var ent model.CustomerEntitlement
entResult := tx.Where("customer_id = ? AND product_id = ?", order.CustomerID, order.ProductID).First(&ent)
renewable := plan.PlanType != "perpetual" && plan.PlanType != "lifetime"
if entResult.Error != nil {
ent = model.CustomerEntitlement{
CustomerID: order.CustomerID,
ProductID: order.ProductID,
PlanID: order.PlanID,
LicenseID: lic.ID,
LicenseKey: lic.LicenseKey,
PlanType: plan.PlanType,
Status: "active",
ValidFrom: now,
ValidUntil: validUntil,
Renewable: renewable,
}
tx.Create(&ent)
} else {
ent.PlanID = order.PlanID
ent.LicenseID = lic.ID
ent.LicenseKey = lic.LicenseKey
ent.PlanType = plan.PlanType
ent.Status = "active"
ent.ValidFrom = now
ent.ValidUntil = validUntil
ent.Renewable = renewable
tx.Save(&ent)
}
utils.LogSystemAction(tx, "payment_completed", "order", order.ID, order.OrderNo,
fmt.Sprintf("订单支付成功,已发放 License: %s", lic.LicenseKey), clientIP)
case "failed", "cancelled":
order.Status = req.Status
if err := tx.Save(&order).Error; err != nil {
return err
}
case "refunded":
order.Status = "refunded"
order.RefundAt = &now
if err := tx.Save(&order).Error; err != nil {
return err
}
// 吊销关联 License // Logan.Li
if order.LicenseID > 0 {
tx.Model(&model.License{}).Where("id = ?", order.LicenseID).
Updates(map[string]interface{}{"status": model.LicenseStatusRevoked, "revoke_reason": "订单退款"})
tx.Model(&model.CustomerEntitlement{}).Where("license_id = ?", order.LicenseID).
Update("status", "revoked")
}
}
return nil
})
}
// ManualGrant 手动发放 License(管理后台) // Logan.Li
func (s *OrderService) ManualGrant(req dto.ManualGrantRequest, operatorID uint, operatorName, clientIP string) (map[string]interface{}, error) {
// 查找或创建客户 // Logan.Li
var customer model.Customer
if err := s.db.Where("email = ?", req.CustomerEmail).First(&customer).Error; err != nil {
name := req.CustomerName
if name == "" {
name = req.CustomerEmail
}
customer = model.Customer{Name: name, Email: req.CustomerEmail, Status: 1}
if err := s.db.Create(&customer).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("定价计划不存在")
}
now := time.Now()
var endDate time.Time
var validUntil *time.Time
if plan.Duration == 0 {
endDate = model.PermanentEndDate
} else {
endDate = now.AddDate(0, 0, plan.Duration)
validUntil = &endDate
}
lic := model.License{
LicenseKey: utils.GenerateLicenseKey(),
CustomerID: customer.ID,
ProductID: req.ProductID,
Status: model.LicenseStatusActive,
StartDate: now,
EndDate: endDate,
Duration: plan.Duration,
MaxActivations: plan.MaxDevices,
Notes: req.Notes,
}
if err := s.db.Create(&lic).Error; err != nil {
return nil, errors.New("创建 License 失败")
}
// 创建/更新权益记录 // Logan.Li
renewable := plan.PlanType != "perpetual" && plan.PlanType != "lifetime"
var ent model.CustomerEntitlement
if err := s.db.Where("customer_id = ? AND product_id = ?", customer.ID, req.ProductID).First(&ent).Error; err != nil {
ent = model.CustomerEntitlement{
CustomerID: customer.ID, ProductID: req.ProductID, PlanID: req.PlanID,
LicenseID: lic.ID, LicenseKey: lic.LicenseKey, PlanType: plan.PlanType,
Status: "active", ValidFrom: now, ValidUntil: validUntil, Renewable: renewable,
}
s.db.Create(&ent)
} else {
ent.PlanID = req.PlanID
ent.LicenseID = lic.ID
ent.LicenseKey = lic.LicenseKey
ent.PlanType = plan.PlanType
ent.Status = "active"
ent.ValidFrom = now
ent.ValidUntil = validUntil
ent.Renewable = renewable
s.db.Save(&ent)
}
utils.LogSystemAction(s.db, "manual_grant", "license", lic.ID, lic.LicenseKey,
fmt.Sprintf("手动发放 License: %s 给客户 %s", lic.LicenseKey, req.CustomerEmail), clientIP)
return map[string]interface{}{
"license_key": lic.LicenseKey,
"license_id": lic.ID,
"customer_id": customer.ID,
}, nil
}
// CheckDuplicatePurchase 防重复购买检测 // Logan.Li
func (s *OrderService) CheckDuplicatePurchase(customerID, productID uint, newPlanType string) dto.DuplicatePurchaseCheckResponse {
var entitlements []model.CustomerEntitlement
s.db.Where("customer_id = ? AND product_id = ?", customerID, productID).Find(&entitlements)
if len(entitlements) == 0 {
return dto.DuplicatePurchaseCheckResponse{AllowPurchase: true}
}
// 已有有效的永久/买断授权:禁止重复购买,但可升级 // Logan.Li
for _, ent := range entitlements {
if ent.Status != "active" {
continue
}
switch ent.PlanType {
case "perpetual", "lifetime":
return dto.DuplicatePurchaseCheckResponse{
AllowPurchase: false,
Reason: "您已拥有该产品的永久授权,无需重复购买",
ExistingEntitlementID: ent.ID,
ExistingLicenseKey: ent.LicenseKey,
ExistingPlanType: ent.PlanType,
CanUpgrade: true,
}
case "monthly", "yearly":
// 订阅可续费 // Logan.Li
if ent.Renewable && (newPlanType == "monthly" || newPlanType == "yearly") {
return dto.DuplicatePurchaseCheckResponse{
AllowPurchase: true,
Reason: "您有有效的订阅,将为您续费",
ExistingEntitlementID: ent.ID,
ExistingLicenseKey: ent.LicenseKey,
ExistingPlanType: ent.PlanType,
CanUpgrade: true,
}
}
}
}
// 已过期但可续费 // Logan.Li
for _, ent := range entitlements {
if ent.Status == "expired" && ent.Renewable {
return dto.DuplicatePurchaseCheckResponse{
AllowPurchase: true,
Reason: "您的订阅已过期,可以续费恢复",
ExistingEntitlementID: ent.ID,
ExistingLicenseKey: ent.LicenseKey,
ExistingPlanType: ent.PlanType,
CanUpgrade: true,
}
}
}
return dto.DuplicatePurchaseCheckResponse{AllowPurchase: true}
}
// CheckPurchaseStatus 查询购买状态 // Logan.Li
func (s *OrderService) CheckPurchaseStatus(customerEmail string, productID uint) (*dto.CheckPurchaseStatusResponse, error) {
var customer model.Customer
if err := s.db.Where("email = ?", customerEmail).First(&customer).Error; err != nil {
return &dto.CheckPurchaseStatusResponse{
Purchased: false, HasActiveLicense: false, Message: "未找到购买记录",
}, nil
}
var ent model.CustomerEntitlement
if err := s.db.Where("customer_id = ? AND product_id = ?", customer.ID, productID).
Preload("Plan").First(&ent).Error; err != nil {
return &dto.CheckPurchaseStatusResponse{
Purchased: false, HasActiveLicense: false, Message: "未购买该产品",
}, nil
}
daysLeft := 0
if ent.ValidUntil != nil {
daysLeft = utils.CalculateDaysLeft(*ent.ValidUntil)
}
// 查询 License 设备数 // Logan.Li
var lic model.License
var currentDevices int64
licenseStatus := ""
maxDevices := 0
if ent.LicenseID > 0 {
s.db.First(&lic, ent.LicenseID)
licenseStatus = lic.Status
maxDevices = lic.MaxActivations
s.db.Model(&model.Activation{}).Where("license_id = ?", lic.ID).Count(&currentDevices)
}
isActive := ent.Status == "active" && (ent.ValidUntil == nil || daysLeft > 0)
var validUntilStr *string
if ent.ValidUntil != nil {
s := ent.ValidUntil.Format("2006-01-02")
validUntilStr = &s
}
return &dto.CheckPurchaseStatusResponse{
Purchased: true,
HasActiveLicense: isActive,
LicenseKey: ent.LicenseKey,
LicenseStatus: licenseStatus,
PlanType: ent.PlanType,
ValidUntil: validUntilStr,
DaysLeft: daysLeft,
Renewable: ent.Renewable,
MaxDevices: maxDevices,
CurrentDevices: int(currentDevices),
EntitlementID: ent.ID,
}, nil
}
// toOrderResponse 模型转响应 DTO // Logan.Li
func toOrderResponse(o model.Order) dto.OrderResponse {
resp := dto.OrderResponse{
ID: o.ID,
OrderNo: o.OrderNo,
CustomerID: o.CustomerID,
ProductID: o.ProductID,
PlanID: o.PlanID,
LicenseID: o.LicenseID,
Amount: o.Amount,
Currency: o.Currency,
Status: o.Status,
PaymentGateway: o.PaymentGateway,
CreatedAt: o.CreatedAt.Format("2006-01-02 15:04:05"),
}
if o.Customer.ID > 0 {
resp.CustomerName = o.Customer.Name
}
if o.Product.ID > 0 {
resp.ProductName = o.Product.Name
}
if o.Plan.ID > 0 {
resp.PlanName = o.Plan.Name
}
if o.PaidAt != nil {
resp.PaidAt = o.PaidAt.Format("2006-01-02 15:04:05")
}
return resp
}