Files
mengyastore/mengyastore-backend-go/internal/handlers/order.go
2026-05-13 12:11:46 +08:00

863 lines
26 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 结账创建订单
// @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
}