Files
opsx-licence/backend/internal/service/order/order_service.go
T
Logan.LiandCursor 4c46088f4f feat: 客户关联产品并支持按产品圈选通知群体
新增客户-产品多对多关联,列表可按产品筛选并复制邮箱;发证/下单自动回填历史关联。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 21:58:35 +08:00

653 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package order
import (
"backend/internal/dto"
"backend/internal/model"
customersvc "backend/internal/service/customer"
"backend/internal/service/payment"
"backend/utils"
"errors"
"fmt"
"math"
"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("创建订单失败")
}
_ = customersvc.AttachProduct(s.db, customer.ID, product.ID)
// 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 领取授权:必须同时提供 order_no + customer_email,防止仅凭邮箱取证 // Logan.Li
func (s *OrderService) ClaimPurchase(req dto.ClaimPurchaseRequest) (*dto.ClaimPurchaseResponse, error) {
orderNo := strings.TrimSpace(req.OrderNo)
email := strings.TrimSpace(req.CustomerEmail)
if orderNo == "" || email == "" {
return nil, errors.New("请同时提供 order_no 与 customer_email")
}
var order model.Order
if err := s.db.Preload("Customer").Where("order_no = ?", orderNo).First(&order).Error; err != nil {
return nil, errors.New("订单不存在")
}
if order.Customer.ID == 0 {
if err := s.db.First(&order.Customer, order.CustomerID).Error; err != nil {
return nil, errors.New("订单客户不存在")
}
}
if !strings.EqualFold(strings.TrimSpace(order.Customer.Email), email) {
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
}
}
// resolveWebhookOrder 仅按 order_no / gateway_order_no 匹配,禁止邮箱兜底劫持 // 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
}
}
return order, errors.New("订单不存在")
}
// PaymentWebhook 支付回调:原子状态流转 + 金额校验 + 退款可达 // Logan.Li
func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP string) error {
status := strings.ToLower(strings.TrimSpace(req.Status))
if status == "completed" {
status = "paid"
}
switch status {
case "paid", "failed", "cancelled", "refunded":
default:
return nil // 未识别状态直接忽略 // Logan.Li
}
order, err := s.resolveWebhookOrder(req)
if err != nil {
return err
}
// 金额/货币校验(有金额时) // Logan.Li
if req.Amount > 0 && (math.Abs(req.Amount-order.Amount) > 0.005 ||
(req.Currency != "" && !strings.EqualFold(req.Currency, order.Currency))) {
utils.LogSystemAction(s.db, "payment_amount_mismatch", "order", order.ID, order.OrderNo,
fmt.Sprintf("webhook %.2f%s 与订单 %.2f%s 不符", req.Amount, req.Currency, order.Amount, order.Currency), clientIP)
return errors.New("支付金额与订单不符")
}
now := time.Now()
updates := map[string]interface{}{
"payment_gateway": req.Gateway,
"payment_method": req.Gateway,
}
if g := strings.TrimSpace(req.GatewayOrderNo); g != "" {
updates["gateway_order_no"] = g
}
switch status {
case "paid":
updates["status"] = "paid"
updates["paid_at"] = now
case "refunded":
updates["status"] = "refunded"
updates["refund_at"] = now
default:
updates["status"] = status
}
// 原子状态流转:refund 只允许 paid→refunded;paid/failed/cancelled 只允许非终态 // Logan.Li
res := s.db.Model(&model.Order{}).Where("id = ?", order.ID).
Where("(status NOT IN ('paid','refunded') AND ? IN ('paid','failed','cancelled')) OR (status = 'paid' AND ? = 'refunded')",
status, status).
Updates(updates)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return nil // 幂等:已处理或非法流转 // Logan.Li
}
return s.db.Transaction(func(tx *gorm.DB) error {
switch status {
case "paid":
var plan model.Plan
if err := tx.First(&plan, order.PlanID).Error; err != nil {
return fmt.Errorf("plan 不存在: %w", err)
}
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
}
_ = customersvc.AttachProduct(tx, order.CustomerID, order.ProductID)
if err := tx.Model(&model.Order{}).Where("id = ?", order.ID).
Update("license_id", lic.ID).Error; err != nil {
return err
}
renewable := plan.PlanType != "perpetual" && plan.PlanType != "lifetime"
var ent model.CustomerEntitlement
entResult := tx.Where("customer_id = ? AND product_id = ?", order.CustomerID, order.ProductID).First(&ent)
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,
}
if err := tx.Create(&ent).Error; err != nil {
return err
}
} 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
if err := tx.Save(&ent).Error; err != nil {
return err
}
}
utils.LogSystemAction(tx, "payment_completed", "order", order.ID, order.OrderNo,
fmt.Sprintf("订单支付成功,已发放 License: %s", lic.LicenseKey), clientIP)
case "refunded":
if order.LicenseID > 0 {
if err := tx.Model(&model.License{}).Where("id = ?", order.LicenseID).
Updates(map[string]interface{}{
"status": model.LicenseStatusRevoked,
"revoke_reason": "订单退款",
}).Error; err != nil {
return err
}
if err := tx.Model(&model.CustomerEntitlement{}).Where("license_id = ?", order.LicenseID).
Update("status", "revoked").Error; err != nil {
return err
}
}
utils.LogSystemAction(tx, "payment_refunded", "order", order.ID, order.OrderNo,
"订单退款,已吊销关联授权", clientIP)
}
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 失败")
}
_ = customersvc.AttachProduct(s.db, customer.ID, req.ProductID)
// 创建/更新权益记录 // 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: "", // 公开接口不回传完整密钥,请走 claim(order+email) // Logan.Li
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
}