diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go index 6d8540d8..8a83bfeb 100644 --- a/backend/internal/handler/payment_webhook_handler.go +++ b/backend/internal/handler/payment_webhook_handler.go @@ -137,13 +137,19 @@ type wxpaySuccessResponse struct { Message string `json:"message"` } +// WeChat Pay webhook success response constants. +const ( + wxpaySuccessCode = "SUCCESS" + wxpaySuccessMessage = "成功" +) + // writeSuccessResponse sends the provider-specific success response. // WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"}; // Stripe expects an empty 200; others accept plain text "success". func writeSuccessResponse(c *gin.Context, providerKey string) { switch providerKey { case payment.TypeWxpay: - c.JSON(http.StatusOK, wxpaySuccessResponse{Code: "SUCCESS", Message: "成功"}) + c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage}) case payment.TypeStripe: c.String(http.StatusOK, "") default: diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 1db104c1..09a68783 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -57,6 +57,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { OIDCOAuthEnabled: settings.OIDCOAuthEnabled, OIDCOAuthProviderName: settings.OIDCOAuthProviderName, BackendModeEnabled: settings.BackendModeEnabled, + PaymentEnabled: settings.PaymentEnabled, Version: h.version, }) } diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index b48a38fe..814e7c4f 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -27,6 +27,8 @@ const ( maxEasypayResponseSize = 1 << 20 // 1MB tradeStatusSuccess = "TRADE_SUCCESS" signTypeMD5 = "MD5" + paymentModePopup = "popup" + deviceMobile = "mobile" ) // EasyPay implements payment.Provider for the EasyPay aggregation platform. @@ -61,7 +63,7 @@ func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRe // Payment mode determined by instance config, not payment type. // "popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php). mode := e.config["paymentMode"] - if mode == "popup" { + if mode == paymentModePopup { return e.createRedirectPayment(req) } return e.createAPIPayment(ctx, req) @@ -106,7 +108,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen params["cid"] = cid } if req.IsMobile { - params["device"] = "mobile" + params["device"] = deviceMobile } params["sign"] = easyPaySign(params, e.config["pkey"]) params["sign_type"] = signTypeMD5 diff --git a/backend/internal/payment/provider/factory.go b/backend/internal/payment/provider/factory.go index 397ca064..0adbd267 100644 --- a/backend/internal/payment/provider/factory.go +++ b/backend/internal/payment/provider/factory.go @@ -9,13 +9,13 @@ import ( // CreateProvider creates a Provider from a provider key, instance ID and decrypted config. func CreateProvider(providerKey string, instanceID string, config map[string]string) (payment.Provider, error) { switch providerKey { - case "easypay": + case payment.TypeEasyPay: return NewEasyPay(instanceID, config) - case "alipay": + case payment.TypeAlipay: return NewAlipay(instanceID, config) - case "wxpay": + case payment.TypeWxpay: return NewWxpay(instanceID, config) - case "stripe": + case payment.TypeStripe: return NewStripe(instanceID, config) default: return nil, fmt.Errorf("unknown provider key: %s", providerKey) diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 9de029e6..2a952ece 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -10,7 +10,6 @@ import ( "time" dbent "github.com/Wei-Shaw/sub2api/ent" - "github.com/Wei-Shaw/sub2api/ent/paymentauditlog" "github.com/Wei-Shaw/sub2api/ent/paymentorder" "github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/payment/provider" @@ -170,68 +169,6 @@ func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, us return nil } -func (s *PaymentService) checkCancelRateLimit(ctx context.Context, userID int64, cfg *PaymentConfig) error { - if !cfg.CancelRateLimitEnabled || cfg.CancelRateLimitMax <= 0 { - return nil - } - windowStart := cancelRateLimitWindowStart(cfg) - operator := fmt.Sprintf("user:%d", userID) - count, err := s.entClient.PaymentAuditLog.Query(). - Where( - paymentauditlog.ActionEQ("ORDER_CANCELLED"), - paymentauditlog.OperatorEQ(operator), - paymentauditlog.CreatedAtGTE(windowStart), - ).Count(ctx) - if err != nil { - slog.Error("check cancel rate limit failed", "userID", userID, "error", err) - return nil // fail open - } - if count >= cfg.CancelRateLimitMax { - return infraerrors.TooManyRequests("CANCEL_RATE_LIMITED", "cancel rate limited"). - WithMetadata(map[string]string{ - "max": strconv.Itoa(cfg.CancelRateLimitMax), - "window": strconv.Itoa(cfg.CancelRateLimitWindow), - "unit": cfg.CancelRateLimitUnit, - }) - } - return nil -} - -func cancelRateLimitWindowStart(cfg *PaymentConfig) time.Time { - now := time.Now() - w := cfg.CancelRateLimitWindow - if w <= 0 { - w = 1 - } - unit := cfg.CancelRateLimitUnit - if unit == "" { - unit = "day" - } - if cfg.CancelRateLimitMode == "fixed" { - switch unit { - case "minute": - t := now.Truncate(time.Minute) - return t.Add(-time.Duration(w-1) * time.Minute) - case "day": - y, m, d := now.Date() - t := time.Date(y, m, d, 0, 0, 0, 0, now.Location()) - return t.AddDate(0, 0, -(w - 1)) - default: // hour - t := now.Truncate(time.Hour) - return t.Add(-time.Duration(w-1) * time.Hour) - } - } - // rolling window - switch unit { - case "minute": - return now.Add(-time.Duration(w) * time.Minute) - case "day": - return now.AddDate(0, 0, -w) - default: // hour - return now.Add(-time.Duration(w) * time.Hour) - } -} - func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, userID int64, amount, limit float64) error { if limit <= 0 { return nil @@ -375,172 +312,3 @@ func (s *PaymentService) AdminListOrders(ctx context.Context, userID int64, p Or } return orders, total, nil } - -// --- Cancel & Expire --- - -func (s *PaymentService) CancelOrder(ctx context.Context, orderID, userID int64) (string, error) { - o, err := s.entClient.PaymentOrder.Get(ctx, orderID) - if err != nil { - return "", infraerrors.NotFound("NOT_FOUND", "order not found") - } - if o.UserID != userID { - return "", infraerrors.Forbidden("FORBIDDEN", "no permission for this order") - } - if o.Status != OrderStatusPending { - return "", infraerrors.BadRequest("INVALID_STATUS", "order cannot be cancelled in current status") - } - return s.cancelCore(ctx, o, OrderStatusCancelled, fmt.Sprintf("user:%d", userID), "user cancelled order") -} - -func (s *PaymentService) AdminCancelOrder(ctx context.Context, orderID int64) (string, error) { - o, err := s.entClient.PaymentOrder.Get(ctx, orderID) - if err != nil { - return "", infraerrors.NotFound("NOT_FOUND", "order not found") - } - if o.Status != OrderStatusPending { - return "", infraerrors.BadRequest("INVALID_STATUS", "order cannot be cancelled in current status") - } - return s.cancelCore(ctx, o, OrderStatusCancelled, "admin", "admin cancelled order") -} - -func (s *PaymentService) cancelCore(ctx context.Context, o *dbent.PaymentOrder, fs, op, ad string) (string, error) { - if o.PaymentTradeNo != "" || o.PaymentType != "" { - if s.checkPaid(ctx, o) == "already_paid" { - return "already_paid", nil - } - } - c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.StatusEQ(OrderStatusPending)).SetStatus(fs).Save(ctx) - if err != nil { - return "", fmt.Errorf("update order status: %w", err) - } - if c > 0 { - auditAction := "ORDER_CANCELLED" - if fs == OrderStatusExpired { - auditAction = "ORDER_EXPIRED" - } - s.writeAuditLog(ctx, o.ID, auditAction, op, map[string]any{"detail": ad}) - } - return "cancelled", nil -} - -func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) string { - prov, err := s.getOrderProvider(ctx, o) - if err != nil { - return "" - } - // Use OutTradeNo as fallback when PaymentTradeNo is empty - // (e.g. EasyPay popup mode where trade_no arrives only via notify callback) - tradeNo := o.PaymentTradeNo - if tradeNo == "" { - tradeNo = o.OutTradeNo - } - resp, err := prov.QueryOrder(ctx, tradeNo) - if err != nil { - slog.Warn("query upstream failed", "orderID", o.ID, "error", err) - return "" - } - if resp.Status == payment.ProviderStatusPaid { - if err := s.HandlePaymentNotification(ctx, &payment.PaymentNotification{TradeNo: o.PaymentTradeNo, OrderID: o.OutTradeNo, Amount: resp.Amount, Status: payment.ProviderStatusSuccess}, prov.ProviderKey()); err != nil { - slog.Error("fulfillment failed during checkPaid", "orderID", o.ID, "error", err) - // Still return already_paid — order was paid, fulfillment can be retried - } - return "already_paid" - } - if cp, ok := prov.(payment.CancelableProvider); ok { - _ = cp.CancelPayment(ctx, tradeNo) - } - return "" -} - -// VerifyOrderByOutTradeNo actively queries the upstream provider to check -// if a payment was made, and processes it if so. This handles the case where -// the provider's notify callback was missed (e.g. EasyPay popup mode). -func (s *PaymentService) VerifyOrderByOutTradeNo(ctx context.Context, outTradeNo string, userID int64) (*dbent.PaymentOrder, error) { - o, err := s.entClient.PaymentOrder.Query(). - Where(paymentorder.OutTradeNo(outTradeNo)). - Only(ctx) - if err != nil { - return nil, infraerrors.NotFound("NOT_FOUND", "order not found") - } - if o.UserID != userID { - return nil, infraerrors.Forbidden("FORBIDDEN", "no permission for this order") - } - // Only verify orders that are still pending or recently expired - if o.Status == OrderStatusPending || o.Status == OrderStatusExpired { - result := s.checkPaid(ctx, o) - if result == "already_paid" { - // Reload order to get updated status - o, err = s.entClient.PaymentOrder.Get(ctx, o.ID) - if err != nil { - return nil, fmt.Errorf("reload order: %w", err) - } - } - } - return o, nil -} - -// VerifyOrderPublic verifies payment status without user authentication. -// Used by the payment result page when the user's session has expired. -func (s *PaymentService) VerifyOrderPublic(ctx context.Context, outTradeNo string) (*dbent.PaymentOrder, error) { - o, err := s.entClient.PaymentOrder.Query(). - Where(paymentorder.OutTradeNo(outTradeNo)). - Only(ctx) - if err != nil { - return nil, infraerrors.NotFound("NOT_FOUND", "order not found") - } - if o.Status == OrderStatusPending || o.Status == OrderStatusExpired { - result := s.checkPaid(ctx, o) - if result == "already_paid" { - o, err = s.entClient.PaymentOrder.Get(ctx, o.ID) - if err != nil { - return nil, fmt.Errorf("reload order: %w", err) - } - } - } - return o, nil -} - -func (s *PaymentService) ExpireTimedOutOrders(ctx context.Context) (int, error) { - now := time.Now() - orders, err := s.entClient.PaymentOrder.Query().Where(paymentorder.StatusEQ(OrderStatusPending), paymentorder.ExpiresAtLTE(now)).All(ctx) - if err != nil { - return 0, fmt.Errorf("query expired: %w", err) - } - n := 0 - for _, o := range orders { - // Check upstream payment status before expiring — the user may have - // paid just before timeout and the webhook hasn't arrived yet. - outcome, _ := s.cancelCore(ctx, o, OrderStatusExpired, "system", "order expired") - if outcome == "already_paid" { - slog.Info("order was paid during expiry", "orderID", o.ID) - continue - } - if outcome != "" { - n++ - } - } - return n, nil -} - -// getOrderProvider creates a provider using the order's original instance config. -// Falls back to registry lookup if instance ID is missing (legacy orders). -func (s *PaymentService) getOrderProvider(ctx context.Context, o *dbent.PaymentOrder) (payment.Provider, error) { - if o.ProviderInstanceID != nil && *o.ProviderInstanceID != "" { - instID, err := strconv.ParseInt(*o.ProviderInstanceID, 10, 64) - if err == nil { - cfg, err := s.loadBalancer.GetInstanceConfig(ctx, instID) - if err == nil { - providerKey := s.registry.GetProviderKey(o.PaymentType) - if providerKey == "" { - providerKey = o.PaymentType - } - p, err := provider.CreateProvider(providerKey, *o.ProviderInstanceID, cfg) - if err == nil { - return p, nil - } - } - } - } - s.EnsureProviders(ctx) - return s.registry.GetProvider(o.PaymentType) -} diff --git a/backend/internal/service/payment_order_lifecycle.go b/backend/internal/service/payment_order_lifecycle.go new file mode 100644 index 00000000..80147180 --- /dev/null +++ b/backend/internal/service/payment_order_lifecycle.go @@ -0,0 +1,257 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/paymentauditlog" + "github.com/Wei-Shaw/sub2api/ent/paymentorder" + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/Wei-Shaw/sub2api/internal/payment/provider" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// --- Cancel & Expire --- + +// Cancel rate limit configuration constants. +const ( + rateLimitUnitDay = "day" + rateLimitUnitMinute = "minute" + rateLimitUnitHour = "hour" + rateLimitModeFixed = "fixed" + checkPaidResultAlreadyPaid = "already_paid" + checkPaidResultCancelled = "cancelled" +) + +func (s *PaymentService) checkCancelRateLimit(ctx context.Context, userID int64, cfg *PaymentConfig) error { + if !cfg.CancelRateLimitEnabled || cfg.CancelRateLimitMax <= 0 { + return nil + } + windowStart := cancelRateLimitWindowStart(cfg) + operator := fmt.Sprintf("user:%d", userID) + count, err := s.entClient.PaymentAuditLog.Query(). + Where( + paymentauditlog.ActionEQ("ORDER_CANCELLED"), + paymentauditlog.OperatorEQ(operator), + paymentauditlog.CreatedAtGTE(windowStart), + ).Count(ctx) + if err != nil { + slog.Error("check cancel rate limit failed", "userID", userID, "error", err) + return nil // fail open + } + if count >= cfg.CancelRateLimitMax { + return infraerrors.TooManyRequests("CANCEL_RATE_LIMITED", "cancel rate limited"). + WithMetadata(map[string]string{ + "max": strconv.Itoa(cfg.CancelRateLimitMax), + "window": strconv.Itoa(cfg.CancelRateLimitWindow), + "unit": cfg.CancelRateLimitUnit, + }) + } + return nil +} + +func cancelRateLimitWindowStart(cfg *PaymentConfig) time.Time { + now := time.Now() + w := cfg.CancelRateLimitWindow + if w <= 0 { + w = 1 + } + unit := cfg.CancelRateLimitUnit + if unit == "" { + unit = rateLimitUnitDay + } + if cfg.CancelRateLimitMode == rateLimitModeFixed { + switch unit { + case rateLimitUnitMinute: + t := now.Truncate(time.Minute) + return t.Add(-time.Duration(w-1) * time.Minute) + case rateLimitUnitDay: + y, m, d := now.Date() + t := time.Date(y, m, d, 0, 0, 0, 0, now.Location()) + return t.AddDate(0, 0, -(w - 1)) + default: // hour + t := now.Truncate(time.Hour) + return t.Add(-time.Duration(w-1) * time.Hour) + } + } + // rolling window + switch unit { + case rateLimitUnitMinute: + return now.Add(-time.Duration(w) * time.Minute) + case rateLimitUnitDay: + return now.AddDate(0, 0, -w) + default: // hour + return now.Add(-time.Duration(w) * time.Hour) + } +} + +func (s *PaymentService) CancelOrder(ctx context.Context, orderID, userID int64) (string, error) { + o, err := s.entClient.PaymentOrder.Get(ctx, orderID) + if err != nil { + return "", infraerrors.NotFound("NOT_FOUND", "order not found") + } + if o.UserID != userID { + return "", infraerrors.Forbidden("FORBIDDEN", "no permission for this order") + } + if o.Status != OrderStatusPending { + return "", infraerrors.BadRequest("INVALID_STATUS", "order cannot be cancelled in current status") + } + return s.cancelCore(ctx, o, OrderStatusCancelled, fmt.Sprintf("user:%d", userID), "user cancelled order") +} + +func (s *PaymentService) AdminCancelOrder(ctx context.Context, orderID int64) (string, error) { + o, err := s.entClient.PaymentOrder.Get(ctx, orderID) + if err != nil { + return "", infraerrors.NotFound("NOT_FOUND", "order not found") + } + if o.Status != OrderStatusPending { + return "", infraerrors.BadRequest("INVALID_STATUS", "order cannot be cancelled in current status") + } + return s.cancelCore(ctx, o, OrderStatusCancelled, "admin", "admin cancelled order") +} + +func (s *PaymentService) cancelCore(ctx context.Context, o *dbent.PaymentOrder, fs, op, ad string) (string, error) { + if o.PaymentTradeNo != "" || o.PaymentType != "" { + if s.checkPaid(ctx, o) == checkPaidResultAlreadyPaid { + return checkPaidResultAlreadyPaid, nil + } + } + c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.StatusEQ(OrderStatusPending)).SetStatus(fs).Save(ctx) + if err != nil { + return "", fmt.Errorf("update order status: %w", err) + } + if c > 0 { + auditAction := "ORDER_CANCELLED" + if fs == OrderStatusExpired { + auditAction = "ORDER_EXPIRED" + } + s.writeAuditLog(ctx, o.ID, auditAction, op, map[string]any{"detail": ad}) + } + return checkPaidResultCancelled, nil +} + +func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) string { + prov, err := s.getOrderProvider(ctx, o) + if err != nil { + return "" + } + // Use OutTradeNo as fallback when PaymentTradeNo is empty + // (e.g. EasyPay popup mode where trade_no arrives only via notify callback) + tradeNo := o.PaymentTradeNo + if tradeNo == "" { + tradeNo = o.OutTradeNo + } + resp, err := prov.QueryOrder(ctx, tradeNo) + if err != nil { + slog.Warn("query upstream failed", "orderID", o.ID, "error", err) + return "" + } + if resp.Status == payment.ProviderStatusPaid { + if err := s.HandlePaymentNotification(ctx, &payment.PaymentNotification{TradeNo: o.PaymentTradeNo, OrderID: o.OutTradeNo, Amount: resp.Amount, Status: payment.ProviderStatusSuccess}, prov.ProviderKey()); err != nil { + slog.Error("fulfillment failed during checkPaid", "orderID", o.ID, "error", err) + // Still return already_paid — order was paid, fulfillment can be retried + } + return checkPaidResultAlreadyPaid + } + if cp, ok := prov.(payment.CancelableProvider); ok { + _ = cp.CancelPayment(ctx, tradeNo) + } + return "" +} + +// VerifyOrderByOutTradeNo actively queries the upstream provider to check +// if a payment was made, and processes it if so. This handles the case where +// the provider's notify callback was missed (e.g. EasyPay popup mode). +func (s *PaymentService) VerifyOrderByOutTradeNo(ctx context.Context, outTradeNo string, userID int64) (*dbent.PaymentOrder, error) { + o, err := s.entClient.PaymentOrder.Query(). + Where(paymentorder.OutTradeNo(outTradeNo)). + Only(ctx) + if err != nil { + return nil, infraerrors.NotFound("NOT_FOUND", "order not found") + } + if o.UserID != userID { + return nil, infraerrors.Forbidden("FORBIDDEN", "no permission for this order") + } + // Only verify orders that are still pending or recently expired + if o.Status == OrderStatusPending || o.Status == OrderStatusExpired { + result := s.checkPaid(ctx, o) + if result == checkPaidResultAlreadyPaid { + // Reload order to get updated status + o, err = s.entClient.PaymentOrder.Get(ctx, o.ID) + if err != nil { + return nil, fmt.Errorf("reload order: %w", err) + } + } + } + return o, nil +} + +// VerifyOrderPublic verifies payment status without user authentication. +// Used by the payment result page when the user's session has expired. +func (s *PaymentService) VerifyOrderPublic(ctx context.Context, outTradeNo string) (*dbent.PaymentOrder, error) { + o, err := s.entClient.PaymentOrder.Query(). + Where(paymentorder.OutTradeNo(outTradeNo)). + Only(ctx) + if err != nil { + return nil, infraerrors.NotFound("NOT_FOUND", "order not found") + } + if o.Status == OrderStatusPending || o.Status == OrderStatusExpired { + result := s.checkPaid(ctx, o) + if result == checkPaidResultAlreadyPaid { + o, err = s.entClient.PaymentOrder.Get(ctx, o.ID) + if err != nil { + return nil, fmt.Errorf("reload order: %w", err) + } + } + } + return o, nil +} + +func (s *PaymentService) ExpireTimedOutOrders(ctx context.Context) (int, error) { + now := time.Now() + orders, err := s.entClient.PaymentOrder.Query().Where(paymentorder.StatusEQ(OrderStatusPending), paymentorder.ExpiresAtLTE(now)).All(ctx) + if err != nil { + return 0, fmt.Errorf("query expired: %w", err) + } + n := 0 + for _, o := range orders { + // Check upstream payment status before expiring — the user may have + // paid just before timeout and the webhook hasn't arrived yet. + outcome, _ := s.cancelCore(ctx, o, OrderStatusExpired, "system", "order expired") + if outcome == checkPaidResultAlreadyPaid { + slog.Info("order was paid during expiry", "orderID", o.ID) + continue + } + if outcome != "" { + n++ + } + } + return n, nil +} + +// getOrderProvider creates a provider using the order's original instance config. +// Falls back to registry lookup if instance ID is missing (legacy orders). +func (s *PaymentService) getOrderProvider(ctx context.Context, o *dbent.PaymentOrder) (payment.Provider, error) { + if o.ProviderInstanceID != nil && *o.ProviderInstanceID != "" { + instID, err := strconv.ParseInt(*o.ProviderInstanceID, 10, 64) + if err == nil { + cfg, err := s.loadBalancer.GetInstanceConfig(ctx, instID) + if err == nil { + providerKey := s.registry.GetProviderKey(o.PaymentType) + if providerKey == "" { + providerKey = o.PaymentType + } + p, err := provider.CreateProvider(providerKey, *o.ProviderInstanceID, cfg) + if err == nil { + return p, nil + } + } + } + } + s.EnsureProviders(ctx) + return s.registry.GetProvider(o.PaymentType) +} diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 25ec15f7..6d8b185e 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -271,11 +271,17 @@ func psSliceContains(sl []string, s string) bool { return false } +// Subscription validity period unit constants. +const ( + validityUnitWeek = "week" + validityUnitMonth = "month" +) + func psComputeValidityDays(days int, unit string) int { switch unit { - case "week": + case validityUnitWeek: return days * 7 - case "month": + case validityUnitMonth: return days * 30 default: return days diff --git a/backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql b/backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql new file mode 100644 index 00000000..5361ad81 --- /dev/null +++ b/backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql @@ -0,0 +1,51 @@ +-- 097_fix_migrated_purchase_menu_label_icon.sql +-- +-- Fixes the custom menu item created by migration 096: updates the label +-- from hardcoded English "Purchase" to "充值/订阅", and sets the icon_svg +-- to a credit-card SVG matching the sidebar CreditCardIcon. +-- +-- Idempotent: only modifies items where id = 'migrated_purchase_subscription'. + +DO $$ +DECLARE + v_raw text; + v_items jsonb; + v_idx int; + v_icon text; + v_elem jsonb; + v_i int := 0; +BEGIN + SELECT value INTO v_raw + FROM settings WHERE key = 'custom_menu_items'; + + IF COALESCE(v_raw, '') = '' OR v_raw = 'null' THEN + RETURN; + END IF; + + v_items := v_raw::jsonb; + + -- Find the index of the migrated item by iterating the array + v_idx := NULL; + FOR v_elem IN SELECT jsonb_array_elements(v_items) LOOP + IF v_elem ->> 'id' = 'migrated_purchase_subscription' THEN + v_idx := v_i; + EXIT; + END IF; + v_i := v_i + 1; + END LOOP; + + IF v_idx IS NULL THEN + RETURN; -- item not found, nothing to fix + END IF; + + -- Credit card SVG (Heroicons outline, matches CreditCardIcon in AppSidebar) + v_icon := ''; + + -- Update label and icon_svg + v_items := jsonb_set(v_items, ARRAY[v_idx::text, 'label'], '"充值/订阅"'::jsonb); + v_items := jsonb_set(v_items, ARRAY[v_idx::text, 'icon_svg'], to_jsonb(v_icon)); + + UPDATE settings SET value = v_items::text WHERE key = 'custom_menu_items'; + + RAISE NOTICE '[migration-097] Fixed migrated_purchase_subscription: label=充值/订阅, icon=CreditCard SVG'; +END $$; diff --git a/frontend/src/components/admin/payment/AdminOrderDetail.vue b/frontend/src/components/admin/payment/AdminOrderDetail.vue index b10bc76b..de4b00b5 100644 --- a/frontend/src/components/admin/payment/AdminOrderDetail.vue +++ b/frontend/src/components/admin/payment/AdminOrderDetail.vue @@ -113,6 +113,7 @@ import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' import type { PaymentOrder } from '@/types/payment' +import { statusBadgeClass, canRefund as canRefundStatus, formatOrderDateTime } from '@/components/payment/orderUtils' const { t } = useI18n() @@ -128,22 +129,11 @@ const emit = defineEmits<{ (e: 'refund', order: PaymentOrder): void }>() -function statusBadgeClass(status: string): string { - const m: Record = { - PENDING: 'badge-warning', PAID: 'badge-info', RECHARGING: 'badge-info', - COMPLETED: 'badge-success', EXPIRED: 'badge-secondary', CANCELLED: 'badge-secondary', - FAILED: 'badge-danger', REFUND_REQUESTED: 'badge-warning', REFUNDING: 'badge-warning', - PARTIALLY_REFUNDED: 'badge-warning', REFUNDED: 'badge-info', REFUND_FAILED: 'badge-danger', - } - return m[status] || 'badge-secondary' -} - function canRefund(order: PaymentOrder): boolean { - return ['COMPLETED', 'PARTIALLY_REFUNDED', 'REFUND_REQUESTED', 'REFUND_FAILED'].includes(order.status) + return canRefundStatus(order.status) } function formatDateTime(dateStr: string): string { - if (!dateStr) return '-' - return new Date(dateStr).toLocaleString() + return formatOrderDateTime(dateStr) } diff --git a/frontend/src/components/admin/payment/AdminOrderTable.vue b/frontend/src/components/admin/payment/AdminOrderTable.vue index 61ffbfa8..0a22930e 100644 --- a/frontend/src/components/admin/payment/AdminOrderTable.vue +++ b/frontend/src/components/admin/payment/AdminOrderTable.vue @@ -108,7 +108,7 @@ {{ t('payment.admin.retry') }}