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

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

502 lines
18 KiB
Go

package order
import (
"backend/internal/dto"
"backend/internal/model"
orderservice "backend/internal/service/order"
"backend/utils"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// OrderController 订单控制器 // Logan.Li
type OrderController struct {
svc *orderservice.OrderService
db *gorm.DB
}
// NewOrderController 创建控制器实例 // Logan.Li
func NewOrderController(db *gorm.DB) *OrderController {
return &OrderController{svc: orderservice.NewOrderService(db), db: db}
}
// List 订单列表(管理后台) // Logan.Li
func (ctrl *OrderController) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
status := c.Query("status")
productID, _ := strconv.ParseUint(c.Query("product_id"), 10, 32)
orders, total, err := ctrl.svc.List(page, pageSize, status, uint(productID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{
"list": orders, "total": total, "page": page, "page_size": pageSize,
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
}})
}
// GetDetail 订单详情 // Logan.Li
func (ctrl *OrderController) GetDetail(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
order, err := ctrl.svc.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": 404, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": order})
}
// CreateOrder 客户端下单(公开接口) // Logan.Li
func (ctrl *OrderController) CreateOrder(c *gin.Context) {
var req dto.CreateOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
return
}
resp, err := ctrl.svc.CreateOrder(req, utils.GetClientIP(c), c.GetHeader("User-Agent"))
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": 2001, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "订单创建成功", "data": resp})
}
// Checkout desktop-friendly checkout entry. // Logan.Li
func (ctrl *OrderController) Checkout(c *gin.Context) {
var req dto.CheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
return
}
resp, err := ctrl.svc.Checkout(req, utils.GetClientIP(c), c.GetHeader("User-Agent"))
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": 2001, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "订单创建成功", "data": resp})
}
// ClaimPurchase fetch license after payment. // Logan.Li
func (ctrl *OrderController) ClaimPurchase(c *gin.Context) {
var req dto.ClaimPurchaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
return
}
resp, err := ctrl.svc.ClaimPurchase(req)
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": 2002, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
}
// PaymentWebhook 支付回调(公开接口,供支付渠道调用) // Logan.Li
func (ctrl *OrderController) PaymentWebhook(c *gin.Context) {
var req dto.PaymentWebhookRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "Invalid payload"})
return
}
if err := ctrl.svc.PaymentWebhook(req, utils.GetClientIP(c)); err != nil {
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "Webhook processed"})
}
// WaffoWebhookReady GET/HEAD — browser-friendly landing (not raw JSON). // Logan.Li
func (ctrl *OrderController) WaffoWebhookReady(c *gin.Context) {
orderNo := firstNonEmpty(c.Query("order_no"), c.Query("orderNo"))
if orderNo != "" {
// Browser return with orderNo → show success/claim page.
c.Redirect(http.StatusFound, "/api/v1/purchase/success?"+c.Request.URL.RawQuery)
return
}
if prefersJSON(c) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "waffo webhook ready",
"methods": []string{"POST"},
})
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(renderSimplePage(
"OpsXShell 支付回调",
"接口已就绪",
"这是给支付平台调用的服务端回调地址,请在 Waffo 后台配置为 Webhook(POST)。支付完成后会自动跳转到授权领取页。",
"",
"",
)))
}
// PurchaseSuccess browser success / claim page after Waffo checkout. // Logan.Li
func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) {
orderNo := firstNonEmpty(c.Query("order_no"), c.Query("orderNo"))
email := firstNonEmpty(c.Query("email"), c.Query("customer_email"), c.Query("customerEmail"))
planType := firstNonEmpty(c.Query("planType"), c.Query("plan_type"))
// If Waffo bounced here with paid-like query, try to settle once. // Logan.Li
statusHint := strings.ToLower(firstNonEmpty(c.Query("status"), c.Query("payment_status"), c.Query("paymentStatus")))
if orderNo != "" && (strings.Contains(statusHint, "paid") || strings.Contains(statusHint, "success") || strings.Contains(statusHint, "complete")) {
_ = ctrl.svc.PaymentWebhook(dto.PaymentWebhookRequest{
EventType: "browser_return",
OrderNo: orderNo,
Status: "paid",
Gateway: "waffo",
Payload: c.Request.URL.RawQuery,
}, utils.GetClientIP(c))
}
title := "支付结果"
headline := "正在确认支付…"
body := "若你已完成付款,请稍等数秒后刷新本页;或打开 OpsXShell,在「账户」粘贴许可证激活。"
licenseKey := ""
orderStatus := ""
if orderNo != "" {
var order model.Order
if err := ctrl.db.Where("order_no = ?", orderNo).First(&order).Error; err == nil {
orderStatus = order.Status
switch order.Status {
case "paid":
headline = "支付成功"
if order.LicenseID > 0 {
var lic model.License
if err := ctrl.db.First(&lic, order.LicenseID).Error; err == nil {
licenseKey = lic.LicenseKey
}
}
if licenseKey == "" {
claimed, err := ctrl.svc.ClaimPurchase(dto.ClaimPurchaseRequest{
OrderNo: orderNo,
CustomerEmail: email,
ProductCode: "opsxshell",
})
if err == nil && claimed != nil {
licenseKey = claimed.LicenseKey
}
}
if licenseKey != "" {
body = "请复制下方许可证,回到 OpsXShell → 设置 / 升级页 → 粘贴激活。也可点击「打开应用」自动回跳。"
} else {
body = "订单已支付,许可证生成中,请稍后刷新本页。"
}
case "pending":
headline = "等待支付确认"
body = fmt.Sprintf("订单 %s 尚未收到支付平台回调。若你已付款,通常 1~2 分钟内会自动到账;也可联系客服并提供订单号。", html.EscapeString(orderNo))
default:
headline = "订单状态:" + order.Status
body = "如有疑问请携带订单号联系支持。"
}
} else {
headline = "未找到订单"
body = "请确认是否从 OpsXShell 内发起购买。订单号:" + html.EscapeString(orderNo)
}
} else if email != "" {
claimed, err := ctrl.svc.ClaimPurchase(dto.ClaimPurchaseRequest{
CustomerEmail: email,
ProductCode: "opsxshell",
})
if err == nil && claimed != nil && claimed.LicenseKey != "" {
headline = "支付成功"
licenseKey = claimed.LicenseKey
orderNo = claimed.OrderNo
orderStatus = claimed.Status
body = "已根据邮箱找到可用许可证,请复制后在 OpsXShell 中激活。"
}
}
deepLink := "opsxshell://pro/activated"
q := []string{}
if orderNo != "" {
q = append(q, "orderNo="+orderNo)
}
if email != "" {
q = append(q, "email="+email)
}
if licenseKey != "" {
q = append(q, "licenseKey="+licenseKey)
}
if planType != "" {
q = append(q, "planType="+planType)
}
if len(q) > 0 {
deepLink += "?" + strings.Join(q, "&")
}
meta := ""
if orderNo != "" {
meta += "<p class=\"meta\">订单号:" + html.EscapeString(orderNo)
if orderStatus != "" {
meta += " · 状态:" + html.EscapeString(orderStatus)
}
meta += "</p>"
}
if email != "" {
meta += "<p class=\"meta\">邮箱:" + html.EscapeString(email) + "</p>"
}
keyBlock := ""
if licenseKey != "" {
keyBlock = `<div class="key-box"><div class="key-label">许可证</div><code id="lic">` + html.EscapeString(licenseKey) + `</code>
<button type="button" onclick="navigator.clipboard.writeText(document.getElementById('lic').innerText)">复制</button></div>`
}
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(renderSimplePage(
"OpsXShell · "+title,
headline,
body,
meta+keyBlock,
deepLink,
)))
}
func prefersJSON(c *gin.Context) bool {
accept := c.GetHeader("Accept")
return strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/html")
}
func renderSimplePage(title, headline, body, extraHTML, deepLink string) string {
openBtn := ""
autoScript := ""
if deepLink != "" {
openBtn = `<p><a class="btn" href="` + html.EscapeString(deepLink) + `">打开 OpsXShell 激活</a></p>`
autoScript = `<script>setTimeout(function(){ try{ window.location.href=` + strconv.Quote(deepLink) + `; }catch(e){} }, 800);</script>`
}
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>` + html.EscapeString(title) + `</title>
<style>
:root { color-scheme: dark; }
body{margin:0;min-height:100vh;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
background:radial-gradient(1200px 600px at 20% -10%,#1e3a5f 0%,#0b1220 55%,#070b14 100%);color:#e8eef7;display:flex;align-items:center;justify-content:center;padding:24px;}
.card{width:min(520px,100%);background:rgba(18,26,40,.92);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px 26px;box-shadow:0 24px 60px rgba(0,0,0,.35);}
.brand{font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:#7fb0ff;margin-bottom:10px;}
h1{font-size:24px;margin:0 0 10px;font-weight:650;}
p{line-height:1.6;color:#b7c3d6;margin:0 0 12px;}
.meta{font-size:13px;color:#8a98ad;}
.key-box{margin:16px 0;padding:14px;border-radius:12px;background:#0a101a;border:1px solid rgba(127,176,255,.25);}
.key-label{font-size:12px;color:#8a98ad;margin-bottom:6px;}
code{display:block;font-size:15px;word-break:break-all;color:#f4f7fb;margin-bottom:10px;}
button,.btn{appearance:none;border:0;border-radius:10px;padding:10px 14px;background:#3b82f6;color:#fff;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block;}
button:hover,.btn:hover{background:#2563eb;}
</style>
</head>
<body>
<div class="card">
<div class="brand">OpsXShell</div>
<h1>` + html.EscapeString(headline) + `</h1>
<p>` + html.EscapeString(body) + `</p>
` + extraHTML + `
` + openBtn + `
</div>
` + autoScript + `
</body>
</html>`
}
// WaffoWebhook maps official Waffo Raw JSON webhooks (order.completed etc.) to PaymentWebhook.
// Prefer data.orderMerchantExternalId (= our ORD-*) for matching; also accept legacy shapes. // Logan.Li
func (ctrl *OrderController) WaffoWebhook(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "无法读取请求体"})
return
}
sigHeader := c.GetHeader("X-Waffo-Signature")
_ = sigHeader // optional RSA verify when WAFFO_WEBHOOK_PUBLIC_KEY is set later // Logan.Li
var payload map[string]interface{}
if len(body) > 0 {
if err := json.Unmarshal(body, &payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "Invalid JSON"})
return
}
} else {
payload = map[string]interface{}{}
}
data, _ := payload["data"].(map[string]interface{})
metaTop, _ := payload["metadata"].(map[string]interface{})
metaOrder, _ := data["orderMetadata"].(map[string]interface{})
metaLegacy, _ := data["metadata"].(map[string]interface{})
// Official: orderMerchantExternalId is our ORD-*; orderId is Waffo ORD_*. // Logan.Li
merchantExternalID := firstNonEmpty(
asString(data["orderMerchantExternalId"]),
asString(payload["orderMerchantExternalId"]),
asString(metaOrder["orderNo"]),
asString(metaOrder["order_no"]),
asString(metaLegacy["orderNo"]),
asString(metaLegacy["order_no"]),
asString(metaTop["orderNo"]),
asString(metaTop["order_no"]),
asString(payload["order_no"]),
asString(payload["orderNo"]),
c.Query("order_no"),
c.Query("orderNo"),
)
gatewayOrderNo := firstNonEmpty(
asString(data["orderId"]),
asString(payload["orderId"]),
asString(payload["gateway_order_no"]),
asString(payload["gatewayOrderNo"]),
asString(payload["id"]),
)
paymentID := firstNonEmpty(asString(data["paymentId"]), asString(payload["paymentId"]))
buyerEmail := firstNonEmpty(asString(data["buyerEmail"]), asString(payload["buyerEmail"]), asString(payload["customer_email"]))
eventType := firstNonEmpty(
c.GetHeader("X-Waffo-Event"),
asString(payload["eventType"]),
asString(payload["event_type"]),
asString(payload["type"]),
asString(payload["event"]),
"payment",
)
statusRaw := strings.ToLower(firstNonEmpty(
asString(data["orderStatus"]),
asString(data["paymentStatus"]),
asString(payload["status"]),
asString(payload["payment_status"]),
asString(payload["paymentStatus"]),
))
mappedStatus := "paid"
switch {
case strings.Contains(strings.ToLower(eventType), "refund"), strings.Contains(statusRaw, "refund"):
mappedStatus = "refunded"
case strings.Contains(strings.ToLower(eventType), "fail"), strings.Contains(strings.ToLower(eventType), "cancel"),
strings.Contains(statusRaw, "fail"), strings.Contains(statusRaw, "cancel"):
mappedStatus = "failed"
case strings.Contains(strings.ToLower(eventType), "order.completed"),
strings.Contains(strings.ToLower(eventType), "payment.succeeded"),
statusRaw == "" || strings.Contains(statusRaw, "paid") || strings.Contains(statusRaw, "complete") ||
strings.Contains(statusRaw, "success") || strings.Contains(statusRaw, "succeed"):
mappedStatus = "paid"
default:
mappedStatus = "paid"
}
// Ignore non-terminal noise if event is clearly not a completion (e.g. order.created). // Logan.Li
et := strings.ToLower(eventType)
if strings.Contains(et, "order.created") || strings.Contains(et, "order.pending") ||
strings.Contains(et, "payment.pending") || strings.Contains(et, "checkout.session") {
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ignored event"})
return
}
if merchantExternalID == "" && gatewayOrderNo == "" && buyerEmail == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "missing orderMerchantExternalId / orderId"})
return
}
req := dto.PaymentWebhookRequest{
EventType: eventType,
OrderNo: merchantExternalID,
GatewayOrderNo: firstNonEmpty(gatewayOrderNo, paymentID),
Status: mappedStatus,
CustomerEmail: buyerEmail,
Gateway: "waffo",
Payload: string(body),
Signature: sigHeader,
}
if err := ctrl.svc.PaymentWebhook(req, utils.GetClientIP(c)); err != nil {
// Still 200 so Waffo does not retry forever on business errors; log via message. // Logan.Li
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "Webhook processed"})
}
func asString(v interface{}) string {
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case float64:
if t == float64(int64(t)) {
return strconv.FormatInt(int64(t), 10)
}
return strconv.FormatFloat(t, 'f', -1, 64)
case json.Number:
return t.String()
default:
return ""
}
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
// ManualGrant 手动发放 License(管理后台) // Logan.Li
func (ctrl *OrderController) ManualGrant(c *gin.Context) {
var req dto.ManualGrantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
return
}
userID, _ := c.Get("user_id")
username, _ := c.Get("username")
data, err := ctrl.svc.ManualGrant(req, userID.(uint), username.(string), utils.GetClientIP(c))
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "License 发放成功", "data": data})
}
// CheckDuplicatePurchase 防重复购买检测(公开接口) // Logan.Li
func (ctrl *OrderController) CheckDuplicatePurchase(c *gin.Context) {
var req dto.DuplicatePurchaseCheckRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
// 通过 CheckPurchaseStatus 间接判断:有记录且有效则不允许重复购买 // Logan.Li
status, _ := ctrl.svc.CheckPurchaseStatus(req.CustomerEmail, req.ProductID)
result := dto.DuplicatePurchaseCheckResponse{AllowPurchase: true}
if status.HasActiveLicense {
result.AllowPurchase = false
result.Reason = status.Message
if result.Reason == "" {
result.Reason = "您已拥有该产品的有效授权"
}
result.ExistingLicenseKey = status.LicenseKey
result.ExistingPlanType = status.PlanType
result.CanUpgrade = true
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": result})
}
// CheckPurchaseStatus 查询购买状态(公开接口) // Logan.Li
func (ctrl *OrderController) CheckPurchaseStatus(c *gin.Context) {
var req dto.CheckPurchaseStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
resp, _ := ctrl.svc.CheckPurchaseStatus(req.CustomerEmail, req.ProductID)
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
}