Files
opsx-licence/backend/internal/controller/order/order_controller.go
T
Logan.Li 1c50d565eb
CI / backend (push) Canceled after 0s
fix(security): webhook fail-closed、机密字段信封加密、限流信任代理修正
三项 P0 安全修复:

1. Webhook 验签 fail-closed
   - WaffoWebhook 未配置验签材料时从「告警放行」改为 503 拒绝,
     封死伪造 order.completed 结算订单的路径
   - 通用 PaymentWebhook 从「仅 release 拒绝」收紧为默认拒绝,
     仅显式 ALLOW_UNVERIFIED_WEBHOOK=1(本地调试)放行

2. 机密字段信封加密(新增 internal/secretbox)
   - AES-256-GCM,主密钥 OPSLIC_MASTER_KEY(64 位 hex),密文带
     enc:v1: 前缀;Encrypt 幂等、历史明文原样放行
   - PaymentGatewayConfig.SecretKey/WebhookSecret 与 PeerNode.
     SharedKey 挂 GORM 钩子透明加解密,业务代码零改动;SharedKey
     列宽 128→512(密文更长)
   - 启动 fail-fast 校验主密钥;MigratePlaintext 幂等迁移存量明文;
     节点同步只含业务表,机密表为节点本地,互不影响
   - docker-compose/.env.example 补 OPSLIC_MASTER_KEY 与说明

3. 限流信任代理修正
   - 默认 SetTrustedProxies(nil) 不信任任何 X-Forwarded-For,
     封死伪造 XFF 轮换 IP 绕过按 IP 限流;反代节点用
     TRUSTED_PROXIES 指定内网 CIDR,非法配置拒绝启动

验证:go build/vet/test 全绿(含 secretbox 6 条单测);
读取路径(Waffo 私钥/webhook 密钥/互联密钥)确认全走 GORM 钩子。

部署注意:上线须配置 OPSLIC_MASTER_KEY(双节点一致)与
WAFFO_WEBHOOK_PUBLIC_KEY(未配则回调 503,属预期);香港反代
节点配 TRUSTED_PROXIES。
2026-11-01 02:03:26 +08:00

558 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"
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 := strings.TrimSpace(os.Getenv("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 || os.Getenv("ALLOW_UNVERIFIED_WEBHOOK") != "1" {
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})
}