CI / backend (push) Canceled after 0s
- 新增 app_configs 表与 appconfig 注册表:读取优先级 页面 → .env → 默认,删除即回退 - 页面可配 7 项:支付回调基址、成功回跳、Webhook 密钥(加密入库)、未验签放行开关、CORS、可信代理、GeoIP 路径 - 引导密钥(JWT_SECRET / OPSLIC_MASTER_KEY / OFFLINE_LICENSE_SEED)与部署参数只读展示掩码+指纹,永不入库 - 校验:URL 格式、密钥长度≥16、CIDR、release 下禁止开启未验签回调;变更写审计日志 - 消费点改造:支付回调地址、Waffo 回跳、webhook 验签、可信代理、CORS、GeoIP 统一走配置中心 - 前端新增「审计与设置 → 运行配置」页(分组表格 + 来源标记 + 编辑弹窗 + 恢复默认) - .env.example 补注释:哪些必须留在 .env、哪些已收敛到页面
559 lines
20 KiB
Go
559 lines
20 KiB
Go
package order
|
||
|
||
import (
|
||
"backend/internal/dto"
|
||
"backend/internal/model"
|
||
"backend/internal/service/appconfig"
|
||
orderservice "backend/internal/service/order"
|
||
"backend/internal/service/payment"
|
||
"backend/utils"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"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) {
|
||
secret := appconfig.String("INTERNAL_WEBHOOK_SECRET")
|
||
if secret == "" {
|
||
var gw model.PaymentGatewayConfig
|
||
if err := ctrl.db.Where("enabled = ?", true).Order("priority ASC").First(&gw).Error; err == nil {
|
||
secret = strings.TrimSpace(gw.WebhookSecret)
|
||
}
|
||
}
|
||
provided := strings.TrimSpace(firstNonEmpty(
|
||
c.GetHeader("X-Webhook-Secret"),
|
||
c.GetHeader("X-Internal-Webhook-Secret"),
|
||
strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "),
|
||
))
|
||
release := strings.EqualFold(os.Getenv("GIN_MODE"), "release")
|
||
if secret == "" {
|
||
// fail-closed:未配置密钥一律拒绝;仅当显式声明 ALLOW_UNVERIFIED_WEBHOOK=1
|
||
// (本地调试)才放行,防止调试配置带进生产 // Logan.Li
|
||
if release || !appconfig.Bool("ALLOW_UNVERIFIED_WEBHOOK") {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未配置 webhook 密钥,已拒绝"})
|
||
return
|
||
}
|
||
log.Println("[安全警告] ALLOW_UNVERIFIED_WEBHOOK=1:未验签放行通用回调(仅限本地调试)")
|
||
} else if provided == "" || provided != secret {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid webhook secret"})
|
||
return
|
||
}
|
||
|
||
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": utils.InternalMsg(err)})
|
||
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 浏览器回跳仅展示 DB 已确认的支付结果,绝不信任 query 自助结算 // 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"))
|
||
|
||
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 = "支付成功"
|
||
// 仅当邮箱匹配订单客户时展示 License,防止仅凭订单号泄露密钥 // Logan.Li
|
||
if email != "" {
|
||
claimed, err := ctrl.svc.ClaimPurchase(dto.ClaimPurchaseRequest{
|
||
OrderNo: orderNo,
|
||
CustomerEmail: email,
|
||
ProductCode: "opsxshell",
|
||
})
|
||
if err == nil && claimed != nil {
|
||
licenseKey = claimed.LicenseKey
|
||
}
|
||
} else if order.LicenseID > 0 {
|
||
body = "订单已支付。请在 OpsXShell 内用下单邮箱领取许可证,或刷新本页并带上 email 参数。"
|
||
}
|
||
if licenseKey != "" {
|
||
body = "请复制下方许可证,回到 OpsXShell → 设置 / 升级页 → 粘贴激活。也可点击「打开应用」自动回跳。"
|
||
} else if order.LicenseID > 0 && email == "" {
|
||
body = "订单已支付。为保护密钥,请使用下单邮箱在客户端领取,或访问本页并附带 email 参数。"
|
||
} else if licenseKey == "" {
|
||
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 != "" {
|
||
headline = "请提供订单号"
|
||
body = "为保护授权安全,领取许可证需同时提供订单号与下单邮箱。"
|
||
}
|
||
|
||
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 := firstNonEmpty(
|
||
c.GetHeader("X-Waffo-Signature"),
|
||
c.GetHeader("X-Signature"),
|
||
c.GetHeader("X-Webhook-Secret"),
|
||
)
|
||
verifyKey := payment.LoadWaffoWebhookVerifyKey(ctrl.db)
|
||
if verifyKey == "" {
|
||
// fail-closed:未配置验签材料时拒绝处理支付回调,而不是告警放行——
|
||
// 否则任何人都能伪造 order.completed 结算订单 // Logan.Li
|
||
log.Println("[安全] 未配置 WAFFO_WEBHOOK_PUBLIC_KEY/SECRET,Waffo 回调已拒绝处理(fail-closed)")
|
||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "message": "webhook 验签未配置,拒绝处理"})
|
||
return
|
||
}
|
||
if err := payment.VerifyWaffoWebhookSignature(verifyKey, body, sigHeader); err != nil {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid signature"})
|
||
return
|
||
}
|
||
|
||
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"]),
|
||
))
|
||
|
||
// 未知事件默认忽略,禁止把任意回调当成 paid // Logan.Li
|
||
mappedStatus := ""
|
||
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"),
|
||
strings.Contains(statusRaw, "paid") || strings.Contains(statusRaw, "complete") ||
|
||
strings.Contains(statusRaw, "success") || strings.Contains(statusRaw, "succeed"):
|
||
mappedStatus = "paid"
|
||
case statusRaw == "" && (strings.Contains(strings.ToLower(eventType), "completed") ||
|
||
strings.Contains(strings.ToLower(eventType), "succeeded")):
|
||
mappedStatus = "paid"
|
||
default:
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ignored unrecognized event"})
|
||
return
|
||
}
|
||
|
||
// 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 == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "missing orderMerchantExternalId / orderId"})
|
||
return
|
||
}
|
||
|
||
amount := 0.0
|
||
if v, ok := data["orderAmount"].(float64); ok {
|
||
amount = v
|
||
} else if v, ok := payload["amount"].(float64); ok {
|
||
amount = v
|
||
}
|
||
currency := firstNonEmpty(asString(data["orderCurrency"]), asString(data["currency"]), asString(payload["currency"]))
|
||
|
||
req := dto.PaymentWebhookRequest{
|
||
EventType: eventType,
|
||
OrderNo: merchantExternalID,
|
||
GatewayOrderNo: firstNonEmpty(gatewayOrderNo, paymentID),
|
||
Status: mappedStatus,
|
||
Amount: amount,
|
||
Currency: currency,
|
||
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": utils.InternalMsg(err)})
|
||
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": utils.InternalMsg(err)})
|
||
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})
|
||
}
|
||
|
||
// BatchDelete 批量删除订单(已支付为财务凭证自动跳过) // Logan.Li
|
||
func (ctrl *OrderController) BatchDelete(c *gin.Context) {
|
||
var req struct {
|
||
IDs []uint `json:"ids" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||
return
|
||
}
|
||
result, err := ctrl.svc.BatchDelete(req.IDs)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"code": 1, "message": utils.InternalMsg(err)})
|
||
return
|
||
}
|
||
utils.LogAction(ctrl.db, c, "batch_delete", "order", 0, "", "", "",
|
||
fmt.Sprintf("批量删除订单 %d 条,跳过 %d 条", result.Deleted, result.Skipped))
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "批量删除完成", "data": result})
|
||
}
|