Files
mengyastore/mengyastore-backend-go/internal/handlers/order.go
2026-05-16 19:03:36 +08:00

872 lines
28 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 handlers
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"mengyastore-backend/internal/auth"
"mengyastore-backend/internal/database"
"mengyastore-backend/internal/email"
"mengyastore-backend/internal/models"
"mengyastore-backend/internal/mq"
"mengyastore-backend/internal/payment"
"mengyastore-backend/internal/storage"
)
const qrSize = "320x320"
var errCheckoutProductInactive = errors.New("product inactive")
type checkoutBusyError struct{ wait int }
func (e *checkoutBusyError) Error() string {
return fmt.Sprintf("正在有人购买中...请等待%d秒", e.wait)
}
type sameUserPendingError struct{ wait int }
func (e *sameUserPendingError) Error() string {
return fmt.Sprintf("您有待支付的订单,请完成支付或等待%d秒后重试", e.wait)
}
type OrderHandler struct {
productStore *storage.ProductStore
orderStore *storage.OrderStore
siteStore *storage.SiteStore
authClient *auth.SproutGateClient
mqClient *mq.Client
paymentPendingTTL time.Duration
webhookMengyaSecret string
}
// CheckoutPayload 结账请求体POST /api/checkout
type CheckoutPayload struct {
ProductID string `json:"productId"`
Quantity int `json:"quantity"`
Note string `json:"note"`
ContactPhone string `json:"contactPhone"`
ContactEmail string `json:"contactEmail"`
NotifyEmail string `json:"notifyEmail"`
PaymentMethod string `json:"paymentMethod"`
}
func NewOrderHandler(productStore *storage.ProductStore, orderStore *storage.OrderStore, siteStore *storage.SiteStore, authClient *auth.SproutGateClient, mqClient *mq.Client, pendingPaymentSecs int, webhookMengyaSecret string) *OrderHandler {
secs := pendingPaymentSecs
if secs <= 0 {
secs = 60
}
return &OrderHandler{
productStore: productStore,
orderStore: orderStore,
siteStore: siteStore,
authClient: authClient,
mqClient: mqClient,
paymentPendingTTL: time.Duration(secs) * time.Second,
webhookMengyaSecret: webhookMengyaSecret,
}
}
func (h *OrderHandler) sendOrderNotify(toEmail, toName, productName, orderID string, qty int, codes []string, isManual bool) {
if toEmail == "" {
return
}
cfg, err := h.siteStore.GetSMTPConfig()
if err != nil || !cfg.IsConfiguredEmail() {
return
}
if h.mqClient != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := h.mqClient.PublishOrderEmail(ctx, mq.OrderEmailPayload{
ToEmail: toEmail,
ToName: toName,
ProductName: productName,
OrderID: orderID,
Quantity: qty,
Codes: codes,
IsManual: isManual,
})
cancel()
if err == nil {
slog.Info("mq", "event", "order_email_queued", "order_id", orderID, "to_email", toEmail)
return
}
slog.Warn("mq", "event", "publish_failed", "err", err)
}
go func() {
emailCfg := email.Config{
SMTPHost: cfg.Host,
SMTPPort: cfg.Port,
From: cfg.Email,
Password: cfg.Password,
FromName: cfg.FromName,
}
if err := email.SendOrderNotify(emailCfg, email.OrderNotifyData{
ToEmail: toEmail,
ToName: toName,
ProductName: productName,
OrderID: orderID,
Quantity: qty,
Codes: codes,
IsManual: isManual,
}); err != nil {
slog.Warn("email", "event", "order_notify_failed", "order_id", orderID, "to", toEmail, "err", err)
} else {
slog.Info("email", "event", "order_notify_sent", "order_id", orderID, "to", toEmail)
}
}()
}
func (h *OrderHandler) tryExtractUserWithEmail(c *gin.Context) (account, username, userEmail string) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
return "", "", ""
}
userToken := strings.TrimPrefix(authHeader, "Bearer ")
result, err := h.authClient.VerifyToken(userToken)
if err != nil || !result.Valid || result.User == nil {
return "", "", ""
}
return result.User.Account, result.User.Username, result.User.Email
}
func effectiveUnitPrice(p models.Product) float64 {
if p.Price == 0 {
return 0
}
if p.DiscountPrice > 0 && p.DiscountPrice < p.Price {
return p.DiscountPrice
}
return p.Price
}
func snapMoney(v float64) float64 {
return math.Round(v*100) / 100
}
func (h *OrderHandler) allocateInventory(product *models.Product, qty int) (delivered []string, updated models.Product, err error) {
isFixed := strings.EqualFold(strings.TrimSpace(product.FulfillmentType), "fixed")
if isFixed {
content := strings.TrimSpace(product.FixedContent)
if content == "" {
return nil, models.Product{}, fmt.Errorf("fixed content missing")
}
delivered = make([]string, qty)
for i := 0; i < qty; i++ {
delivered[i] = content
}
up, e := h.productStore.GetByID(product.ID)
return delivered, up, e
}
if product.Quantity < qty {
return nil, models.Product{}, fmt.Errorf("out of stock")
}
deliveredCodes, ok := extractCodes(product, qty)
if !ok {
return nil, models.Product{}, fmt.Errorf("codes insufficient")
}
product.Quantity = len(product.Codes)
up, e := h.productStore.Update(product.ID, *product)
if e != nil {
return nil, models.Product{}, e
}
return deliveredCodes, up, nil
}
func qrFromOrder(orderID, productID string) string {
qrPayload := fmt.Sprintf("order:%s:%s", orderID, productID)
return fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=%s&data=%s", qrSize, url.QueryEscape(qrPayload))
}
// CreateOrder 创建订单(结账)。需登录与否取决于商品 requireLoginBearer 可选传入以关联账户与限购。
// @Summary 结账并创建订单
// @Description 根据商品配置决定是否需要登录(`requireLogin`)。可附带 `Authorization: Bearer` 以关联账号、限购与通知邮箱。付费订单走萌芽支付时需商品已配置收款码;同一商品在他人待支付锁定期内可能返回 409同一账号存在待支付单时也可能冲突。成功返回订单概要及二维码链接等。
// @Tags 订单
// @Accept json
// @Produce json
// @Param Authorization header string false "可选;格式 Bearer + 空格 + token部分商品必填"
// @Param body body CheckoutPayload true "结账请求体:商品、数量、联系方式等"
// @Success 200 {object} SwaggerCheckoutWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 404 {object} SwaggerErrorBody
// @Failure 409 {object} SwaggerErrorBody "库存锁定冲突(他人正在支付)或同账号待支付单未满间隔"
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/checkout [post]
func (h *OrderHandler) CreateOrder(c *gin.Context) {
userAccount, userName, userEmail := h.tryExtractUserWithEmail(c)
var payload CheckoutPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
}
payload.ProductID = strings.TrimSpace(payload.ProductID)
if payload.ProductID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少必填字段"})
return
}
if payload.Quantity <= 0 {
payload.Quantity = 1
}
product, err := h.productStore.GetByID(payload.ProductID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if !product.Active {
c.JSON(http.StatusBadRequest, gin.H{"error": "商品暂时无法购买"})
return
}
if product.RequireLogin && userAccount == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "该商品需要登录后才能购买"})
return
}
if product.MaxPerAccount > 0 && userAccount != "" {
purchased, err := h.orderStore.CountPurchasedByAccount(userAccount, product.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if purchased+payload.Quantity > product.MaxPerAccount {
remain := product.MaxPerAccount - purchased
if remain <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("每个账户最多购买 %d 个,您已达上限", product.MaxPerAccount)})
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("每个账户最多购买 %d 个,您还可购买 %d 个", product.MaxPerAccount, remain)})
}
return
}
}
total := snapMoney(effectiveUnitPrice(product) * float64(payload.Quantity))
rawPM := strings.ToLower(strings.TrimSpace(payload.PaymentMethod))
if total > 0 && (rawPM == "legacy_manual" || rawPM == "legacy" || rawPM == "manual") {
c.JSON(http.StatusBadRequest, gin.H{"error": "该支付方式已停用,请使用萌芽支付"})
return
}
notifyEmail := strings.TrimSpace(userEmail)
if notifyEmail == "" {
notifyEmail = strings.TrimSpace(payload.NotifyEmail)
}
if notifyEmail == "" {
notifyEmail = strings.TrimSpace(payload.ContactEmail)
}
deliveryMode := "auto"
if total > 0 {
hasQR := false
for _, u := range product.PaymentQrURLs {
if strings.TrimSpace(u) != "" {
hasQR = true
break
}
}
if !hasQR {
c.JSON(http.StatusBadRequest, gin.H{"error": "该商品尚未配置萌芽支付收款渠道,请在管理后台上传收款码图片链接"})
return
}
}
if total <= 0 {
deliveredCodes, updatedProduct, err := h.allocateInventory(&product, payload.Quantity)
if err != nil {
msg := err.Error()
switch msg {
case "fixed content missing":
c.JSON(http.StatusBadRequest, gin.H{"error": "该商品为固定内容发货,但未配置发货内容,请联系管理员"})
case "out of stock":
c.JSON(http.StatusBadRequest, gin.H{"error": "库存不足"})
case "codes insufficient":
c.JSON(http.StatusBadRequest, gin.H{"error": "卡密不足"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": msg})
}
return
}
baseOrder := models.Order{
ProductID: updatedProduct.ID,
ProductName: updatedProduct.Name,
UserAccount: userAccount,
UserName: userName,
Quantity: payload.Quantity,
DeliveredCodes: deliveredCodes,
DeliveryMode: deliveryMode,
Note: strings.TrimSpace(payload.Note),
ContactPhone: strings.TrimSpace(payload.ContactPhone),
ContactEmail: strings.TrimSpace(payload.ContactEmail),
NotifyEmail: notifyEmail,
}
baseOrder.Status = "completed"
baseOrder.PaymentMethod = payment.MethodMengya
created, err := h.orderStore.Create(baseOrder)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.productStore.IncrementSold(updatedProduct.ID, payload.Quantity); err != nil {
slog.Warn("order", "event", "increment_sold_failed", "err", err)
}
h.sendOrderNotify(notifyEmail, userName, updatedProduct.Name, created.ID, payload.Quantity, deliveredCodes, false)
resp := gin.H{
"orderId": created.ID,
"qrCodeUrl": qrFromOrder(created.ID, created.ProductID),
"productId": created.ProductID,
"productQty": created.Quantity,
"viewCount": updatedProduct.ViewCount,
"status": created.Status,
"paymentMethod": payment.MethodMengya,
"deliveredCodes": created.DeliveredCodes,
"deliveryMode": created.DeliveryMode,
"isManual": created.DeliveryMode == "manual",
}
c.JSON(http.StatusOK, gin.H{"data": resp})
return
}
var (
created models.Order
updatedProduct models.Product
)
err = h.productStore.Transaction(func(tx *gorm.DB) error {
now := time.Now().UTC()
plock, err := h.productStore.GetByIDForUpdate(tx, payload.ProductID)
if err != nil {
return err
}
if !plock.Active {
return errCheckoutProductInactive
}
var block database.OrderRow
err = tx.Where("product_id = ? AND status = ? AND payment_expires_at IS NOT NULL AND payment_expires_at > ?",
payload.ProductID, "pending_payment", now).
Order("created_at ASC").First(&block).Error
if err == nil {
w := int(math.Ceil(block.PaymentExpiresAt.Sub(now).Seconds()))
if w < 1 {
w = 1
}
req := strings.TrimSpace(userAccount)
holder := strings.TrimSpace(block.UserAccount)
if req != "" && holder == req {
return &sameUserPendingError{wait: w}
}
return &checkoutBusyError{wait: w}
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
deliveredCodes, updated, err := h.allocateInventoryTx(tx, &plock, payload.Quantity)
if err != nil {
return err
}
updatedProduct = updated
exp := time.Now().UTC().Add(h.paymentPendingTTL)
pending := models.Order{
ProductID: updated.ID,
ProductName: updated.Name,
UserAccount: userAccount,
UserName: userName,
Quantity: payload.Quantity,
DeliveredCodes: deliveredCodes,
DeliveryMode: deliveryMode,
Note: strings.TrimSpace(payload.Note),
ContactPhone: strings.TrimSpace(payload.ContactPhone),
ContactEmail: strings.TrimSpace(payload.ContactEmail),
NotifyEmail: notifyEmail,
Status: "pending_payment",
PaymentMethod: payment.MethodMengya,
PaymentExpectedTotal: total,
PaymentExpiresAt: &exp,
}
cr, err := h.orderStore.CreateTx(tx, pending)
if err != nil {
return err
}
created = cr
return nil
})
if err != nil {
var busy *checkoutBusyError
if errors.As(err, &busy) {
c.JSON(http.StatusConflict, gin.H{"error": busy.Error()})
return
}
var dup *sameUserPendingError
if errors.As(err, &dup) {
c.JSON(http.StatusConflict, gin.H{"error": dup.Error()})
return
}
if errors.Is(err, errCheckoutProductInactive) {
c.JSON(http.StatusBadRequest, gin.H{"error": "商品暂时无法购买"})
return
}
msg := err.Error()
switch msg {
case "fixed content missing":
c.JSON(http.StatusBadRequest, gin.H{"error": "该商品为固定内容发货,但未配置发货内容,请联系管理员"})
case "out of stock":
c.JSON(http.StatusBadRequest, gin.H{"error": "库存不足"})
case "codes insufficient":
c.JSON(http.StatusBadRequest, gin.H{"error": "卡密不足"})
case "product not found":
c.JSON(http.StatusNotFound, gin.H{"error": "商品不存在"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": msg})
}
return
}
resp := gin.H{
"orderId": created.ID,
"qrCodeUrl": qrFromOrder(created.ID, created.ProductID),
"productId": created.ProductID,
"productQty": created.Quantity,
"viewCount": updatedProduct.ViewCount,
"status": created.Status,
"paymentMethod": payment.MethodMengya,
"paymentExpectedTotal": created.PaymentExpectedTotal,
"paymentQrUrls": updatedProduct.PaymentQrURLs,
"paymentExpiresAt": created.PaymentExpiresAt,
}
c.JSON(http.StatusOK, gin.H{"data": resp})
}
func confirmResponse(order models.Order) gin.H {
isManual := order.DeliveryMode == "manual"
return gin.H{
"orderId": order.ID,
"status": order.Status,
"deliveryMode": order.DeliveryMode,
"deliveredCodes": order.DeliveredCodes,
"isManual": isManual,
}
}
// ConfirmOrder 用户确认订单状态(手动核销、待支付轮询后取货等)。
// @Summary 确认订单(核销/完成流程)
// @Description 用于用户侧「确认」动作:已完成订单直接返回数据;待支付时若仍未到账则 409已取消或超时 410。手动发货等场景下会触发邮件通知若配置
// @Tags 订单
// @Produce json
// @Param id path string true "订单ID"
// @Success 200 {object} SwaggerConfirmWrap
// @Failure 404 {object} SwaggerErrorBody
// @Failure 409 {object} SwaggerErrorBody "待支付但金额未核对到账等"
// @Failure 410 {object} SwaggerErrorBody "订单已取消或已超过待支付时限"
// @Router /api/orders/{id}/confirm [post]
func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
orderID := c.Param("id")
order, err := h.orderStore.GetByID(orderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
switch order.Status {
case "completed":
isManual := order.DeliveryMode == "manual"
if isManual {
confirmNotifyEmail := order.NotifyEmail
if confirmNotifyEmail == "" {
confirmNotifyEmail = order.ContactEmail
}
h.sendOrderNotify(confirmNotifyEmail, order.UserName, order.ProductName, order.ID, order.Quantity, order.DeliveredCodes, false)
}
c.JSON(http.StatusOK, gin.H{"data": confirmResponse(order)})
return
case "cancelled":
c.JSON(http.StatusGone, gin.H{"error": "订单已取消"})
return
case "pending_payment":
h.syncExpiredPending(order)
order, err = h.orderStore.GetByID(orderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if order.Status == "cancelled" {
c.JSON(http.StatusGone, gin.H{"error": "订单已超时未支付"})
return
}
if order.Status == "completed" {
h.sendOrderNotify(order.NotifyEmail, order.UserName, order.ProductName, order.ID, order.Quantity, order.DeliveredCodes, false)
c.JSON(http.StatusOK, gin.H{"data": confirmResponse(order)})
return
}
c.JSON(http.StatusConflict, gin.H{"error": "等待到账确认,到账后系统将自动核销;您也可稍后再次点击"})
return
default:
// 手动待发货等,状态仍为 pending
order2, err := h.orderStore.Confirm(orderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
isManual := order2.DeliveryMode == "manual"
if isManual {
confirmNotifyEmail := order2.NotifyEmail
if confirmNotifyEmail == "" {
confirmNotifyEmail = order2.ContactEmail
}
h.sendOrderNotify(confirmNotifyEmail, order2.UserName, order2.ProductName, order2.ID, order2.Quantity, order2.DeliveredCodes, false)
}
c.JSON(http.StatusOK, gin.H{"data": confirmResponse(order2)})
return
}
}
// GetOrderPaymentStatus 前端轮询订单支付状态(不返回卡密)。
// @Summary 查询订单支付状态(轮询)
// @Description 供前端轮询待支付订单:返回状态、应付快照、过期时间等;不包含卡密等敏感发货内容。路径参数为订单 ID。
// @Tags 订单
// @Produce json
// @Param id path string true "订单ID"
// @Success 200 {object} SwaggerPaymentStatusWrap
// @Failure 404 {object} SwaggerErrorBody
// @Router /api/orders/{id}/payment-status [get]
func (h *OrderHandler) GetOrderPaymentStatus(c *gin.Context) {
orderID := c.Param("id")
order, err := h.orderStore.GetByID(orderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if order.Status == "pending_payment" {
h.syncExpiredPending(order)
order, _ = h.orderStore.GetByID(orderID)
}
out := gin.H{
"status": order.Status,
"expectedTotal": order.PaymentExpectedTotal,
"paymentExpiresAt": order.PaymentExpiresAt,
"paymentMethod": order.PaymentMethod,
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
func (h *OrderHandler) syncExpiredPending(order models.Order) {
if order.Status != "pending_payment" || order.PaymentExpiresAt == nil {
return
}
if time.Now().UTC().Before(*order.PaymentExpiresAt) {
return
}
if err := h.releasePendingReserved(order); err != nil {
slog.Warn("payment", "event", "expire_release_failed", "order_id", order.ID, "err", err)
}
}
func (h *OrderHandler) releasePendingReserved(o models.Order) error {
changed, err := h.orderStore.CancelPendingStaleIfEligible(o.ID)
if err != nil {
return err
}
if !changed {
return nil
}
up, err := h.productStore.GetByID(o.ProductID)
if err != nil {
return err
}
fixed := strings.EqualFold(strings.TrimSpace(up.FulfillmentType), "fixed")
if !fixed && len(o.DeliveredCodes) > 0 {
if err := h.productStore.PrependReservedCodes(o.ProductID, o.DeliveredCodes); err != nil {
return err
}
}
return nil
}
func (h *OrderHandler) releaseAllExpired(now time.Time) {
list, err := h.orderStore.ListExpiredPendingPayments(now)
if err != nil {
slog.Warn("payment", "event", "list_expired_failed", "err", err)
return
}
for _, o := range list {
if err := h.releasePendingReserved(o); err != nil {
slog.Warn("payment", "event", "expire_sweep_failed", "order_id", o.ID, "err", err)
}
}
}
// RunExpiredPaymentSweep 后台定期释放超时未支付的预留库存。
func (h *OrderHandler) RunExpiredPaymentSweep(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
h.releaseAllExpired(time.Now().UTC())
}
}
}
func webhookSecretMatches(expected string, hdr string) bool {
if expected == "" {
return true
}
got := strings.TrimSpace(hdr)
if len(got) != len(expected) {
return false
}
return subtle.ConstantTimeCompare([]byte(expected), []byte(got)) == 1
}
func webhookLogVerbose() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("WEBHOOK_LOG_VERBOSE"))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func clampOneLine(s string, maxRunes int) string {
if maxRunes <= 0 {
maxRunes = 200
}
s = strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", " "), "\n", " ")
s = strings.TrimSpace(s)
r := []rune(s)
if len(r) > maxRunes {
return string(r[:maxRunes]) + "…"
}
return s
}
// MengyaPaymentWebhook 萌芽支付到账通知Body 为渠道 JSON。若配置 WEBHOOK_MENGYA_SECRET 则必须带 X-Webhook-Secret。
// @Summary 萌芽支付到账 Webhook
// @Description 由支付渠道/转发服务 POST 原始 JSON服务端解析到账金额并尝试匹配待支付订单。若配置 `WEBHOOK_MENGYA_SECRET`,请求头 `X-Webhook-Secret` 必须与其一致。本地联调与生产 `https://store.shumengya.top` 均使用同一路径,由部署时的域名与 TLS 决定回调 URL。
// @Tags Webhook
// @Accept json
// @Produce json
// @Param X-Webhook-Secret header string false "与进程环境变量 WEBHOOK_MENGYA_SECRET 一致;未配置密钥时可不填"
// @Success 200 {object} SwaggerWebhookMengyaResp
// @Failure 400 {object} SwaggerErrorBody
// @Failure 403 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/webhooks/mengya-pay [post]
func (h *OrderHandler) MengyaPaymentWebhook(c *gin.Context) {
verbose := webhookLogVerbose()
if !webhookSecretMatches(h.webhookMengyaSecret, c.GetHeader("X-Webhook-Secret")) {
slog.Warn("mengya webhook", "result", "forbidden", "remote", c.ClientIP())
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
slog.Warn("mengya webhook", "result", "read_body", "err", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "read body"})
return
}
noticeHint := payment.NoticeFieldsForLog(raw)
amount, parseOK := payment.ParseWebhookJSON(raw)
base := []any{
"remote", c.ClientIP(),
"bytes", len(raw),
"parse_ok", parseOK,
"amount", amount,
}
if verbose {
base = append(base,
"notice", clampOneLine(noticeHint, 280),
"body", clampOneLine(string(raw), 600),
)
}
if !parseOK || amount <= 0 {
if json.Valid(bytes.TrimSpace(raw)) && noticeHint == "" {
base = append(base, "hint", "json_without_notice_keys")
}
slog.Warn("mengya webhook", append([]any{"result", "parse_amount_failed"}, base...)...)
c.JSON(http.StatusBadRequest, gin.H{"error": "无法解析到账金额"})
return
}
o, matched, err := h.orderStore.TryFulfillMengyaPayment(amount, time.Now().UTC())
if err != nil {
slog.Error("mengya webhook", append([]any{"result", "fulfill_error", "err", err}, base...)...)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal"})
return
}
orderID := "-"
if matched {
orderID = o.ID
}
result := "no_order_match"
if matched {
result = "ok"
}
slog.Info("mengya webhook", append([]any{"result", result, "matched", matched, "order_id", orderID}, base...)...)
out := gin.H{"ok": true, "matched": matched}
if matched {
fulfilled, err := h.orderStore.GetByID(o.ID)
if err == nil {
h.sendOrderNotify(fulfilled.NotifyEmail, fulfilled.UserName, fulfilled.ProductName, fulfilled.ID, fulfilled.Quantity, fulfilled.DeliveredCodes, false)
out["matched_order_id"] = fulfilled.ID
}
}
c.JSON(http.StatusOK, out)
}
// CancelOrder 用户取消待支付订单并释放预留库存(当前实现不校验 Bearer凭订单 ID 操作)。
// @Summary 取消待支付订单
// @Description 仅对允许取消的状态生效(如待支付);已完成订单会冲突。成功时释放预留库存/卡密占用。请勿在公网暴露此能力时省略鉴权策略(实现以代码为准)。
// @Tags 订单
// @Produce json
// @Param id path string true "订单ID"
// @Success 200 {object} SwaggerCancelWrap
// @Failure 404 {object} SwaggerErrorBody
// @Failure 409 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/orders/{id}/cancel [post]
func (h *OrderHandler) CancelOrder(c *gin.Context) {
orderID := c.Param("id")
order, err := h.orderStore.GetByID(orderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "订单不存在"})
return
}
switch order.Status {
case "cancelled":
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true, "message": "订单已取消"}})
return
case "completed":
c.JSON(http.StatusConflict, gin.H{"error": "已完成的订单无法取消"})
return
case "pending_payment":
if err := h.releasePendingReserved(order); err != nil {
slog.Warn("order", "event", "cancel_release_failed", "order_id", order.ID, "err", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "取消失败,请稍候再试"})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true, "message": "订单已取消"}})
return
default:
c.JSON(http.StatusConflict, gin.H{"error": "当前订单状态不支持取消"})
return
}
}
// ListMyOrders 当前登录用户的订单列表(待支付条目不返回卡密)。
// @Summary 查询当前登录用户的订单列表
// @Description 必须携带有效 Bearer。列表中处于待支付状态的订单不会包含已分配卡密等敏感字段防止泄露。
// @Tags 订单
// @Produce json
// @Security BearerAuth
// @Success 200 {object} SwaggerOrdersBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/orders [get]
func (h *OrderHandler) ListMyOrders(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
userToken := strings.TrimPrefix(authHeader, "Bearer ")
result, err := h.authClient.VerifyToken(userToken)
if err != nil || !result.Valid || result.User == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
return
}
orders, err := h.orderStore.ListByAccount(result.User.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range orders {
if orders[i].Status == "pending_payment" {
orders[i].DeliveredCodes = nil
}
}
c.JSON(http.StatusOK, gin.H{"data": orders})
}
func (h *OrderHandler) allocateInventoryTx(tx *gorm.DB, product *models.Product, qty int) (delivered []string, updated models.Product, err error) {
isFixed := strings.EqualFold(strings.TrimSpace(product.FulfillmentType), "fixed")
if isFixed {
content := strings.TrimSpace(product.FixedContent)
if content == "" {
return nil, models.Product{}, fmt.Errorf("fixed content missing")
}
delivered = make([]string, qty)
for i := 0; i < qty; i++ {
delivered[i] = content
}
up, e := h.productStore.GetByIDForUpdate(tx, product.ID)
return delivered, up, e
}
if product.Quantity < qty {
return nil, models.Product{}, fmt.Errorf("out of stock")
}
deliveredCodes, ok := extractCodes(product, qty)
if !ok {
return nil, models.Product{}, fmt.Errorf("codes insufficient")
}
product.Quantity = len(product.Codes)
up, e := h.productStore.UpdateTx(tx, product.ID, *product)
if e != nil {
return nil, models.Product{}, e
}
return deliveredCodes, up, nil
}
func extractCodes(product *models.Product, count int) ([]string, bool) {
if count <= 0 {
return nil, false
}
if len(product.Codes) < count {
return nil, false
}
delivered := make([]string, count)
copy(delivered, product.Codes[:count])
product.Codes = product.Codes[count:]
return delivered, true
}