863 lines
26 KiB
Go
863 lines
26 KiB
Go
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 创建订单(结账)。需登录与否取决于商品 requireLogin;Bearer 可选传入以关联账户与限购。
|
||
// @Summary 结账创建订单
|
||
// @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,
|
||
}
|
||
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 确认订单
|
||
// @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 订单支付状态
|
||
// @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
|
||
// @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 取消订单
|
||
// @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 我的订单
|
||
// @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
|
||
}
|