fix: 按评审报告止血支付漏洞并加固授权链路
废除浏览器回跳自助结算,通用 webhook 强制密钥鉴权,Waffo 支持验签与未知事件忽略;重构订单状态机/激活占座/SQLite WAL,并补备份、readyz、限流与前端导出/登录页安全修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
8cc2c83f9d
commit
bac72e6945
24 files changed
+682
-391
No files matched your search
@@ -7,6 +7,7 @@ import (
|
||||
"backend/utils"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -193,125 +194,72 @@ func (s *OrderService) Checkout(req dto.CheckoutRequest, clientIP, userAgent str
|
||||
}, clientIP, userAgent)
|
||||
}
|
||||
|
||||
// ClaimPurchase fetch license after payment by order_no or email+product. // Logan.Li
|
||||
// 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)
|
||||
productCode := strings.TrimSpace(req.ProductCode)
|
||||
if productCode == "" {
|
||||
productCode = "opsxshell"
|
||||
if orderNo == "" || email == "" {
|
||||
return nil, errors.New("请同时提供 order_no 与 customer_email")
|
||||
}
|
||||
|
||||
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
|
||||
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 email == "" {
|
||||
return nil, errors.New("请提供 order_no 或 customer_email")
|
||||
if !strings.EqualFold(strings.TrimSpace(order.Customer.Email), email) {
|
||||
return nil, errors.New("订单与邮箱不匹配")
|
||||
}
|
||||
|
||||
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 {
|
||||
switch order.Status {
|
||||
case "pending":
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
return &dto.ClaimPurchaseResponse{
|
||||
Status: ent.Status,
|
||||
Message: "暂无可用授权",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveWebhookOrder finds local order by merchant external id, Waffo order id, or buyer email. // Logan.Li
|
||||
// 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 != "" {
|
||||
@@ -328,46 +276,72 @@ func (s *OrderService) resolveWebhookOrder(req dto.PaymentWebhookRequest) (model
|
||||
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
|
||||
// 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
|
||||
}
|
||||
if order.Status == "paid" || order.Status == "refunded" {
|
||||
return nil // 已处理,幂等返回
|
||||
|
||||
// 金额/货币校验(有金额时) // 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("支付金额与订单不符")
|
||||
}
|
||||
|
||||
// 事务处理 // 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 发放 License // Logan.Li
|
||||
// 原子状态流转: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
|
||||
tx.First(&plan, order.PlanID)
|
||||
if err := tx.First(&plan, order.PlanID).Error; err != nil {
|
||||
return fmt.Errorf("plan 不存在: %w", err)
|
||||
}
|
||||
|
||||
var validUntil *time.Time
|
||||
var endDate time.Time
|
||||
@@ -391,14 +365,14 @@ func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP st
|
||||
if err := tx.Create(&lic).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.Order{}).Where("id = ?", order.ID).
|
||||
Update("license_id", lic.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
order.LicenseID = lic.ID
|
||||
tx.Save(&order)
|
||||
|
||||
// 更新权益记录(防重复购买核心) // Logan.Li
|
||||
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)
|
||||
renewable := plan.PlanType != "perpetual" && plan.PlanType != "lifetime"
|
||||
if entResult.Error != nil {
|
||||
ent = model.CustomerEntitlement{
|
||||
CustomerID: order.CustomerID,
|
||||
@@ -412,7 +386,9 @@ func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP st
|
||||
ValidUntil: validUntil,
|
||||
Renewable: renewable,
|
||||
}
|
||||
tx.Create(&ent)
|
||||
if err := tx.Create(&ent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
ent.PlanID = order.PlanID
|
||||
ent.LicenseID = lic.ID
|
||||
@@ -422,31 +398,30 @@ func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP st
|
||||
ent.ValidFrom = now
|
||||
ent.ValidUntil = validUntil
|
||||
ent.Renewable = renewable
|
||||
tx.Save(&ent)
|
||||
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 "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")
|
||||
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
|
||||
})
|
||||
@@ -630,7 +605,7 @@ func (s *OrderService) CheckPurchaseStatus(customerEmail string, productID uint)
|
||||
return &dto.CheckPurchaseStatusResponse{
|
||||
Purchased: true,
|
||||
HasActiveLicense: isActive,
|
||||
LicenseKey: ent.LicenseKey,
|
||||
LicenseKey: "", // 公开接口不回传完整密钥,请走 claim(order+email) // Logan.Li
|
||||
LicenseStatus: licenseStatus,
|
||||
PlanType: ent.PlanType,
|
||||
ValidUntil: validUntilStr,
|
||||
|
||||
Reference in new issue
Block a user