mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-04 21:20:51 +08:00
Merge pull request #1764 from touwaeriol/feat/wxpay-pubkey-hardening
feat(payment): wxpay pubkey-only hardening with structured errors and i18n
This commit is contained in:
@@ -3,16 +3,17 @@ package provider
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
|
||||
@@ -56,15 +57,35 @@ type Wxpay struct {
|
||||
notifyHandler *notify.Handler
|
||||
}
|
||||
|
||||
const wxpayAPIv3KeyLength = 32
|
||||
|
||||
func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) {
|
||||
required := []string{"appId", "mchId", "privateKey", "apiV3Key", "publicKey", "publicKeyId", "certSerial"}
|
||||
// All fields are required. Platform-certificate mode is intentionally unsupported —
|
||||
// WeChat has been migrating all merchants to the pubkey verifier since 2024-10,
|
||||
// and newly-provisioned merchants cannot download platform certificates at all.
|
||||
required := []string{"appId", "mchId", "privateKey", "apiV3Key", "certSerial", "publicKey", "publicKeyId"}
|
||||
for _, k := range required {
|
||||
if config[k] == "" {
|
||||
return nil, fmt.Errorf("wxpay config missing required key: %s", k)
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_MISSING_KEY", "missing_required_key").
|
||||
WithMetadata(map[string]string{"key": k})
|
||||
}
|
||||
}
|
||||
if len(config["apiV3Key"]) != 32 {
|
||||
return nil, fmt.Errorf("wxpay apiV3Key must be exactly 32 bytes, got %d", len(config["apiV3Key"]))
|
||||
if len(config["apiV3Key"]) != wxpayAPIv3KeyLength {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY_LENGTH", "invalid_key_length").
|
||||
WithMetadata(map[string]string{
|
||||
"key": "apiV3Key",
|
||||
"expected": strconv.Itoa(wxpayAPIv3KeyLength),
|
||||
"actual": strconv.Itoa(len(config["apiV3Key"])),
|
||||
})
|
||||
}
|
||||
// Parse PEMs eagerly so malformed keys surface at save time, not at order creation.
|
||||
if _, err := utils.LoadPrivateKey(formatPEM(config["privateKey"], "PRIVATE KEY")); err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "privateKey"})
|
||||
}
|
||||
if _, err := utils.LoadPublicKey(formatPEM(config["publicKey"], "PUBLIC KEY")); err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "publicKey"})
|
||||
}
|
||||
return &Wxpay{instanceID: instanceID, config: config}, nil
|
||||
}
|
||||
@@ -89,14 +110,19 @@ func (w *Wxpay) ensureClient() (*core.Client, error) {
|
||||
if w.coreClient != nil {
|
||||
return w.coreClient, nil
|
||||
}
|
||||
privateKey, publicKey, err := w.loadKeyPair()
|
||||
privateKey, err := utils.LoadPrivateKey(formatPEM(w.config["privateKey"], "PRIVATE KEY"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "privateKey"})
|
||||
}
|
||||
publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY"))
|
||||
if err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "publicKey"})
|
||||
}
|
||||
certSerial := w.config["certSerial"]
|
||||
verifier := verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey)
|
||||
client, err := core.NewClient(context.Background(),
|
||||
option.WithMerchantCredential(w.config["mchId"], certSerial, privateKey),
|
||||
option.WithMerchantCredential(w.config["mchId"], w.config["certSerial"], privateKey),
|
||||
option.WithVerifier(verifier))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay init client: %w", err)
|
||||
@@ -110,18 +136,6 @@ func (w *Wxpay) ensureClient() (*core.Client, error) {
|
||||
return w.coreClient, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) loadKeyPair() (*rsa.PrivateKey, *rsa.PublicKey, error) {
|
||||
privateKey, err := utils.LoadPrivateKey(formatPEM(w.config["privateKey"], "PRIVATE KEY"))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("wxpay load private key: %w", err)
|
||||
}
|
||||
publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY"))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("wxpay load public key: %w", err)
|
||||
}
|
||||
return privateKey, publicKey, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
client, err := w.ensureClient()
|
||||
if err != nil {
|
||||
|
||||
@@ -3,12 +3,36 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
// generateTestKeyPair returns a fresh RSA 2048 key pair as PEM strings.
|
||||
// The wechatpay-go SDK expects PKCS8 private keys and PKIX public keys.
|
||||
func generateTestKeyPair(t *testing.T) (privPEM, pubPEM string) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate rsa key: %v", err)
|
||||
}
|
||||
privDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pkcs8: %v", err)
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pkix: %v", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
|
||||
string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
|
||||
}
|
||||
|
||||
func TestMapWxState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -149,13 +173,14 @@ func TestFormatPEM(t *testing.T) {
|
||||
func TestNewWxpay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
privPEM, pubPEM := generateTestKeyPair(t)
|
||||
validConfig := map[string]string{
|
||||
"appId": "wx1234567890",
|
||||
"mchId": "1234567890",
|
||||
"privateKey": "fake-private-key",
|
||||
"privateKey": privPEM,
|
||||
"apiV3Key": "12345678901234567890123456789012", // exactly 32 bytes
|
||||
"publicKey": "fake-public-key",
|
||||
"publicKeyId": "key-id-001",
|
||||
"publicKey": pubPEM,
|
||||
"publicKeyId": "PUB_KEY_ID_TEST",
|
||||
"certSerial": "SERIAL001",
|
||||
}
|
||||
|
||||
@@ -206,6 +231,12 @@ func TestNewWxpay(t *testing.T) {
|
||||
wantErr: true,
|
||||
errSubstr: "apiV3Key",
|
||||
},
|
||||
{
|
||||
name: "missing certSerial",
|
||||
config: withOverride(map[string]string{"certSerial": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "certSerial",
|
||||
},
|
||||
{
|
||||
name: "missing publicKey",
|
||||
config: withOverride(map[string]string{"publicKey": ""}),
|
||||
@@ -218,17 +249,29 @@ func TestNewWxpay(t *testing.T) {
|
||||
wantErr: true,
|
||||
errSubstr: "publicKeyId",
|
||||
},
|
||||
{
|
||||
name: "malformed privateKey PEM",
|
||||
config: withOverride(map[string]string{"privateKey": "not-a-valid-pem"}),
|
||||
wantErr: true,
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY",
|
||||
},
|
||||
{
|
||||
name: "malformed publicKey PEM",
|
||||
config: withOverride(map[string]string{"publicKey": "not-a-valid-pem"}),
|
||||
wantErr: true,
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY",
|
||||
},
|
||||
{
|
||||
name: "apiV3Key too short",
|
||||
config: withOverride(map[string]string{"apiV3Key": "short"}),
|
||||
wantErr: true,
|
||||
errSubstr: "exactly 32 bytes",
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY_LENGTH",
|
||||
},
|
||||
{
|
||||
name: "apiV3Key too long",
|
||||
config: withOverride(map[string]string{"apiV3Key": "123456789012345678901234567890123"}), // 33 bytes
|
||||
wantErr: true,
|
||||
errSubstr: "exactly 32 bytes",
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY_LENGTH",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,22 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment/provider"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
// validateProviderConfig runs the provider's constructor to surface config-level
|
||||
// errors at save time (e.g. wxpay missing certSerial), instead of only failing
|
||||
// when an order is created. Returns the structured ApplicationError from the
|
||||
// constructor so the frontend i18n layer can localize it.
|
||||
//
|
||||
// Only validates enabled instances — a disabled instance may be a half-filled
|
||||
// draft the admin will complete later.
|
||||
func (s *PaymentConfigService) validateProviderConfig(providerKey string, config map[string]string) error {
|
||||
_, err := provider.CreateProvider(providerKey, "_validate_", config)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Provider Instance CRUD ---
|
||||
|
||||
func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) {
|
||||
@@ -48,9 +61,8 @@ func (s *PaymentConfigService) ListProviderInstancesWithConfig(ctx context.Conte
|
||||
resp := ProviderInstanceResponse{
|
||||
ID: int64(inst.ID), ProviderKey: inst.ProviderKey, Name: inst.Name,
|
||||
SupportedTypes: splitTypes(inst.SupportedTypes), Limits: inst.Limits,
|
||||
Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled,
|
||||
AllowUserRefund: inst.AllowUserRefund,
|
||||
SortOrder: inst.SortOrder, PaymentMode: inst.PaymentMode,
|
||||
Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, AllowUserRefund: inst.AllowUserRefund,
|
||||
SortOrder: inst.SortOrder, PaymentMode: inst.PaymentMode,
|
||||
}
|
||||
resp.Config, err = s.decryptAndMaskConfig(inst.ProviderKey, inst.Config)
|
||||
if err != nil {
|
||||
@@ -138,6 +150,11 @@ func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req C
|
||||
if err := validateProviderRequest(req.ProviderKey, req.Name, typesStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Enabled {
|
||||
if err := s.validateProviderConfig(req.ProviderKey, req.Config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
enc, err := s.encryptConfig(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -211,16 +228,42 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
|
||||
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
|
||||
}
|
||||
}
|
||||
// Validate merged config when the instance will end up enabled.
|
||||
// This surfaces provider-level errors (e.g. wxpay missing certSerial) at save time,
|
||||
// so admins see them in the dialog instead of only when an order is created.
|
||||
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load provider instance: %w", err)
|
||||
}
|
||||
finalEnabled := inst.Enabled
|
||||
if req.Enabled != nil {
|
||||
finalEnabled = *req.Enabled
|
||||
}
|
||||
var mergedConfig map[string]string
|
||||
if req.Config != nil {
|
||||
mergedConfig, err = s.mergeConfig(ctx, id, req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if finalEnabled {
|
||||
configToValidate := mergedConfig
|
||||
if configToValidate == nil {
|
||||
configToValidate, err = s.decryptConfig(inst.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt existing config: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.validateProviderConfig(inst.ProviderKey, configToValidate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
u := s.entClient.PaymentProviderInstance.UpdateOneID(id)
|
||||
if req.Name != nil {
|
||||
u.SetName(*req.Name)
|
||||
}
|
||||
if req.Config != nil {
|
||||
merged, err := s.mergeConfig(ctx, id, req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enc, err := s.encryptConfig(merged)
|
||||
if mergedConfig != nil {
|
||||
enc, err := s.encryptConfig(mergedConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
@@ -167,7 +168,7 @@ func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, us
|
||||
return fmt.Errorf("count pending orders: %w", err)
|
||||
}
|
||||
if c >= max {
|
||||
return infraerrors.TooManyRequests("TOO_MANY_PENDING", fmt.Sprintf("too many pending orders (max %d)", max)).
|
||||
return infraerrors.TooManyRequests("TOO_MANY_PENDING", "too_many_pending").
|
||||
WithMetadata(map[string]string{"max": strconv.Itoa(max)})
|
||||
}
|
||||
return nil
|
||||
@@ -191,7 +192,8 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user
|
||||
used += o.Amount
|
||||
}
|
||||
if used+amount > limit {
|
||||
return infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", fmt.Sprintf("daily recharge limit reached, remaining: %.2f", math.Max(0, limit-used)))
|
||||
return infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily_limit_exceeded").
|
||||
WithMetadata(map[string]string{"remaining": fmt.Sprintf("%.2f", math.Max(0, limit-used))})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -201,21 +203,37 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
|
||||
// This enables cross-provider load balancing (e.g. EasyPay + Alipay direct for "alipay").
|
||||
sel, err := s.loadBalancer.SelectInstance(ctx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount)
|
||||
if err != nil {
|
||||
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment method (%s) is not configured", req.PaymentType))
|
||||
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "method_not_configured").
|
||||
WithMetadata(map[string]string{"payment_type": req.PaymentType})
|
||||
}
|
||||
if sel == nil {
|
||||
return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no available payment instance")
|
||||
return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no_available_instance")
|
||||
}
|
||||
prov, err := provider.CreateProvider(sel.ProviderKey, sel.InstanceID, sel.Config)
|
||||
if err != nil {
|
||||
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable")
|
||||
slog.Error("[PaymentService] CreateProvider failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
|
||||
// If the provider returned a structured ApplicationError (e.g. WXPAY_CONFIG_MISSING_KEY),
|
||||
// pass it through with provider context added to metadata. Otherwise wrap as PAYMENT_PROVIDER_MISCONFIGURED.
|
||||
if appErr := new(infraerrors.ApplicationError); errors.As(err, &appErr) {
|
||||
md := map[string]string{"provider": sel.ProviderKey, "instance_id": sel.InstanceID}
|
||||
for k, v := range appErr.Metadata {
|
||||
md[k] = v
|
||||
}
|
||||
return nil, appErr.WithMetadata(md)
|
||||
}
|
||||
return nil, infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_MISCONFIGURED", "provider_misconfigured").
|
||||
WithMetadata(map[string]string{"provider": sel.ProviderKey, "instance_id": sel.InstanceID})
|
||||
}
|
||||
subject := s.buildPaymentSubject(plan, limitAmount, cfg)
|
||||
outTradeNo := order.OutTradeNo
|
||||
pr, err := prov.CreatePayment(ctx, payment.CreatePaymentRequest{OrderID: outTradeNo, Amount: payAmountStr, PaymentType: req.PaymentType, Subject: subject, ClientIP: req.ClientIP, IsMobile: req.IsMobile, InstanceSubMethods: sel.SupportedTypes})
|
||||
if err != nil {
|
||||
slog.Error("[PaymentService] CreatePayment failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
|
||||
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error()))
|
||||
if appErr := new(infraerrors.ApplicationError); errors.As(err, &appErr) {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment_gateway_error").
|
||||
WithMetadata(map[string]string{"provider": sel.ProviderKey, "instance_id": sel.InstanceID})
|
||||
}
|
||||
_, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).SetNillablePayURL(psNilIfEmpty(pr.PayURL)).SetNillableQrCode(psNilIfEmpty(pr.QRCode)).SetNillableProviderInstanceID(psNilIfEmpty(sel.InstanceID)).Save(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -78,7 +78,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import QRCode from 'qrcode'
|
||||
@@ -222,7 +222,7 @@ async function handleCancel() {
|
||||
cleanup()
|
||||
emit('close')
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
@@ -242,7 +242,7 @@ async function handleCancel() {
|
||||
cleanup()
|
||||
outcome.value = 'cancelled'
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
|
||||
@@ -132,7 +132,7 @@ onMounted(async () => {
|
||||
selectedType.value = event.value.type
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
|
||||
initError.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.stripeLoadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -186,7 +186,7 @@ async function handlePay() {
|
||||
emit('success')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
error.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.result.failed'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -199,7 +199,7 @@ async function handleCancel() {
|
||||
await paymentAPI.cancelOrder(props.orderId)
|
||||
emit('back')
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
|
||||
@@ -99,9 +99,9 @@ export const PROVIDER_CONFIG_FIELDS: Record<string, ConfigFieldDef[]> = {
|
||||
{ key: 'mchId', label: '', sensitive: false },
|
||||
{ key: 'privateKey', label: '', sensitive: true },
|
||||
{ key: 'apiV3Key', label: '', sensitive: true },
|
||||
{ key: 'certSerial', label: '', sensitive: false },
|
||||
{ key: 'publicKey', label: '', sensitive: true },
|
||||
{ key: 'publicKeyId', label: '', sensitive: false, optional: true },
|
||||
{ key: 'certSerial', label: '', sensitive: false, optional: true },
|
||||
{ key: 'publicKeyId', label: '', sensitive: false },
|
||||
],
|
||||
stripe: [
|
||||
{ key: 'secretKey', label: '', sensitive: true },
|
||||
|
||||
@@ -5432,7 +5432,33 @@ export default {
|
||||
errors: {
|
||||
tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.',
|
||||
cancelRateLimited: 'Too many cancellations. Please try again later.',
|
||||
// Structured error codes (reason strings from backend ApplicationError)
|
||||
PAYMENT_DISABLED: 'Payment system is disabled.',
|
||||
USER_INACTIVE: 'Your account is disabled.',
|
||||
BALANCE_PAYMENT_DISABLED: 'Balance recharge has been disabled.',
|
||||
INVALID_AMOUNT: 'Invalid amount.',
|
||||
INVALID_INPUT: 'Invalid request.',
|
||||
PLAN_NOT_AVAILABLE: 'Plan not found or no longer available.',
|
||||
GROUP_NOT_FOUND: 'Subscription group is no longer available.',
|
||||
GROUP_TYPE_MISMATCH: 'Group is not a subscription type.',
|
||||
TOO_MANY_PENDING: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.',
|
||||
DAILY_LIMIT_EXCEEDED: 'Daily recharge limit reached. Remaining: {remaining}.',
|
||||
PAYMENT_GATEWAY_ERROR: 'Payment method is unavailable.',
|
||||
NO_AVAILABLE_INSTANCE: 'No payment channel available right now.',
|
||||
PAYMENT_PROVIDER_MISCONFIGURED: 'Payment provider misconfigured. Please contact an administrator.',
|
||||
WXPAY_CONFIG_MISSING_KEY: 'WeChat Pay config missing required key: {key}.',
|
||||
WXPAY_CONFIG_INVALID_KEY_LENGTH: 'WeChat Pay {key} length is invalid (expected {expected} bytes, got {actual}).',
|
||||
WXPAY_CONFIG_INVALID_KEY: 'WeChat Pay {key} is malformed. Make sure you copied the full PEM content.',
|
||||
PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.',
|
||||
CANCEL_RATE_LIMITED: 'Too many cancellations. Please try again later.',
|
||||
NOT_FOUND: 'Order not found.',
|
||||
FORBIDDEN: 'No permission for this order.',
|
||||
CONFLICT: 'Order status has changed. Please refresh.',
|
||||
INVALID_ORDER_TYPE: 'Only balance orders can request a refund.',
|
||||
INVALID_STATUS: 'The current order status does not allow this operation.',
|
||||
BALANCE_NOT_ENOUGH: 'Refund amount exceeds balance.',
|
||||
REFUND_AMOUNT_EXCEEDED: 'Refund amount exceeds the recharge amount.',
|
||||
REFUND_FAILED: 'Refund failed.',
|
||||
},
|
||||
stripePay: 'Pay Now',
|
||||
stripeSuccessProcessing: 'Payment successful, processing your order...',
|
||||
|
||||
@@ -5620,7 +5620,33 @@ export default {
|
||||
errors: {
|
||||
tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单',
|
||||
cancelRateLimited: '取消订单过于频繁,请稍后再试',
|
||||
// Structured error codes (reason strings from backend ApplicationError)
|
||||
PAYMENT_DISABLED: '支付系统已关闭',
|
||||
USER_INACTIVE: '账号已被禁用',
|
||||
BALANCE_PAYMENT_DISABLED: '余额充值功能已关闭',
|
||||
INVALID_AMOUNT: '金额无效',
|
||||
INVALID_INPUT: '参数有误',
|
||||
PLAN_NOT_AVAILABLE: '套餐不存在或已下架',
|
||||
GROUP_NOT_FOUND: '订阅分组不可用',
|
||||
GROUP_TYPE_MISMATCH: '分组类型不是订阅类型',
|
||||
TOO_MANY_PENDING: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单',
|
||||
DAILY_LIMIT_EXCEEDED: '今日充值已达上限,剩余额度 {remaining}',
|
||||
PAYMENT_GATEWAY_ERROR: '支付方式不可用',
|
||||
NO_AVAILABLE_INSTANCE: '暂无可用的支付通道',
|
||||
PAYMENT_PROVIDER_MISCONFIGURED: '支付通道配置错误,请联系管理员',
|
||||
WXPAY_CONFIG_MISSING_KEY: '微信支付配置缺少必填项:{key}',
|
||||
WXPAY_CONFIG_INVALID_KEY_LENGTH: '微信支付 {key} 长度错误,应为 {expected} 字节(实际 {actual})',
|
||||
WXPAY_CONFIG_INVALID_KEY: '微信支付 {key} 格式错误,请确认复制了完整的 PEM 内容',
|
||||
PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作',
|
||||
CANCEL_RATE_LIMITED: '取消订单过于频繁,请稍后再试',
|
||||
NOT_FOUND: '订单不存在',
|
||||
FORBIDDEN: '无权限操作此订单',
|
||||
CONFLICT: '订单状态已变更,请刷新',
|
||||
INVALID_ORDER_TYPE: '仅余额订单可申请退款',
|
||||
INVALID_STATUS: '当前订单状态不允许此操作',
|
||||
BALANCE_NOT_ENOUGH: '退款金额超过余额',
|
||||
REFUND_AMOUNT_EXCEEDED: '退款金额超过充值金额',
|
||||
REFUND_FAILED: '退款失败',
|
||||
},
|
||||
stripePay: '立即支付',
|
||||
stripeSuccessProcessing: '支付成功,正在处理订单...',
|
||||
|
||||
@@ -23,14 +23,96 @@ interface ApiErrorLike {
|
||||
|
||||
/**
|
||||
* Extract the error code from an API error object.
|
||||
*
|
||||
* Prefers the string `reason` (e.g. "PAYMENT_PROVIDER_MISCONFIGURED") over the
|
||||
* numeric HTTP `code`, because reason is granular enough to drive i18n lookup
|
||||
* while HTTP code is not.
|
||||
*/
|
||||
export function extractApiErrorCode(err: unknown): string | undefined {
|
||||
if (!err || typeof err !== 'object') return undefined
|
||||
const e = err as ApiErrorLike
|
||||
const code = e.code ?? e.reason ?? e.response?.data?.code
|
||||
const code = e.reason ?? e.code ?? e.response?.data?.code
|
||||
return code != null ? String(code) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata (interpolation params) from an API error object.
|
||||
* Backend errors carry `metadata` with template variables that fill i18n placeholders.
|
||||
*/
|
||||
export function extractApiErrorMetadata(err: unknown): Record<string, unknown> | undefined {
|
||||
if (!err || typeof err !== 'object') return undefined
|
||||
const e = err as ApiErrorLike
|
||||
return e.metadata
|
||||
}
|
||||
|
||||
type TranslateFn = (key: string, params?: Record<string, unknown>) => string
|
||||
type TranslateWithExistsFn = TranslateFn & { te?: (key: string) => boolean }
|
||||
|
||||
/**
|
||||
* Translate a value via i18n if a matching key exists, otherwise return the original.
|
||||
* Example: "certSerial" → t('admin.settings.payment.field_certSerial') → "证书序列号".
|
||||
*/
|
||||
function tryTranslate(t: TranslateFn, key: string, fallback: string): string {
|
||||
const translated = t(key)
|
||||
if (translated === key) return fallback
|
||||
const te = (t as TranslateWithExistsFn).te
|
||||
if (te && !te(key)) return fallback
|
||||
return translated
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace raw config field names in metadata (e.g. "certSerial") with their
|
||||
* localized UI labels (e.g. "证书序列号"), using the provider-config field i18n namespace.
|
||||
* Handles both single `key` and `/`-joined `keys` patterns used by wxpay errors.
|
||||
*/
|
||||
function localizeMetadata(metadata: Record<string, unknown>, t: TranslateFn): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...metadata }
|
||||
if (typeof out.key === 'string') {
|
||||
out.key = tryTranslate(t, `admin.settings.payment.field_${out.key}`, out.key)
|
||||
}
|
||||
if (typeof out.keys === 'string') {
|
||||
out.keys = out.keys
|
||||
.split('/')
|
||||
.map(k => tryTranslate(t, `admin.settings.payment.field_${k}`, k))
|
||||
.join(' / ')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a localized error message from an API error by looking up
|
||||
* `<namespace>.<REASON>` in i18n and substituting metadata as placeholders.
|
||||
*
|
||||
* Config-field names in metadata (`key` / `keys`) are automatically translated
|
||||
* to their UI labels before substitution, so error messages read like
|
||||
* "缺少必填项:证书序列号" instead of "缺少必填项:certSerial".
|
||||
*
|
||||
* @param err - The caught error
|
||||
* @param t - Vue i18n translate function
|
||||
* @param namespace- i18n key prefix, e.g. "payment.errors"
|
||||
* @param fallback - Fallback key or plain string if no localized mapping exists
|
||||
*/
|
||||
export function extractI18nErrorMessage(
|
||||
err: unknown,
|
||||
t: TranslateFn,
|
||||
namespace: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
const code = extractApiErrorCode(err)
|
||||
if (code) {
|
||||
const key = `${namespace}.${code}`
|
||||
const rawMetadata = extractApiErrorMetadata(err) ?? {}
|
||||
const metadata = localizeMetadata(rawMetadata, t)
|
||||
const translated = t(key, metadata)
|
||||
// Vue i18n returns the key itself when missing; detect that and fall back.
|
||||
if (translated !== key) return translated
|
||||
// If the framework exposes `te`, use it to double-check.
|
||||
const te = (t as TranslateWithExistsFn).te
|
||||
if (te && te(key)) return translated
|
||||
}
|
||||
return extractApiErrorMessage(err, fallback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a displayable error message from an API error.
|
||||
*
|
||||
|
||||
@@ -2850,7 +2850,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import ImageUpload from '@/components/common/ImageUpload.vue'
|
||||
import BackupSettings from '@/views/admin/BackupView.vue'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { useAdminSettingsStore } from '@/stores/adminSettings'
|
||||
import {
|
||||
@@ -4085,14 +4085,10 @@ const cancelRateLimitModeOptions = computed(() => [
|
||||
{ value: 'fixed', label: t('admin.settings.payment.cancelRateLimitWindowModeFixed') },
|
||||
])
|
||||
|
||||
const paymentErrorMap = computed(() => ({
|
||||
PENDING_ORDERS: t('payment.errors.PENDING_ORDERS'),
|
||||
}))
|
||||
|
||||
async function loadProviders() {
|
||||
providersLoading.value = true
|
||||
try { const res = await adminAPI.payment.getProviders(); providers.value = res.data || [] }
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
|
||||
finally { providersLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -4122,7 +4118,7 @@ async function handleSaveProvider(payload: Partial<ProviderInstance>) {
|
||||
// Auto-save settings so provider changes take effect immediately
|
||||
await saveSettings()
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value))
|
||||
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
||||
} finally {
|
||||
providerSaving.value = false
|
||||
}
|
||||
@@ -4148,7 +4144,7 @@ async function handleToggleField(provider: ProviderInstance, field: 'enabled' |
|
||||
} else {
|
||||
provider.allow_user_refund = newValue
|
||||
}
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
|
||||
} catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
|
||||
}
|
||||
|
||||
async function handleToggleType(provider: ProviderInstance, type: string) {
|
||||
@@ -4158,7 +4154,7 @@ async function handleToggleType(provider: ProviderInstance, type: string) {
|
||||
try {
|
||||
await adminAPI.payment.updateProvider(provider.id, { supported_types: updated } as any)
|
||||
provider.supported_types = updated
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
|
||||
} catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
|
||||
}
|
||||
|
||||
function confirmDeleteProvider(provider: ProviderInstance) {
|
||||
@@ -4177,7 +4173,7 @@ async function handleReorderProviders(updates: { id: number; sort_order: number
|
||||
if (p) p.sort_order = u.sort_order
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
||||
loadProviders()
|
||||
}
|
||||
}
|
||||
@@ -4189,7 +4185,7 @@ async function handleDeleteProvider() {
|
||||
appStore.showSuccess(t('common.deleted'))
|
||||
showDeleteProviderDialog.value = false
|
||||
loadProviders()
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
|
||||
} catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -116,7 +116,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminPaymentAPI } from '@/api/admin/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { formatOrderDateTime } from '@/components/payment/orderUtils'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
@@ -167,7 +167,7 @@ async function loadOrders() {
|
||||
orders.value = res.data.items || []
|
||||
orderPagination.total = res.data.total || 0
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
||||
} finally { ordersLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -214,12 +214,12 @@ async function showOrderDetail(order: PaymentOrder) {
|
||||
|
||||
async function handleCancelOrder(order: PaymentOrder) {
|
||||
try { await adminPaymentAPI.cancelOrder(order.id); appStore.showSuccess(t('payment.admin.orderCancelled')); loadOrders() }
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
|
||||
}
|
||||
|
||||
async function handleRetryOrder(order: PaymentOrder) {
|
||||
try { await adminPaymentAPI.retryRecharge(order.id); appStore.showSuccess(t('payment.admin.retrySuccess')); loadOrders() }
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
|
||||
}
|
||||
|
||||
function openRefundDialog(order: PaymentOrder) { selectedOrder.value = order; showRefundDialog.value = true }
|
||||
@@ -230,7 +230,7 @@ async function handleRefund(data: { amount: number; reason: string; deduct_balan
|
||||
try {
|
||||
await adminPaymentAPI.refundOrder(selectedOrder.value.id, { amount: data.amount, reason: data.reason, deduct_balance: data.deduct_balance, force: data.force })
|
||||
appStore.showSuccess(t('payment.admin.refundSuccess')); showRefundDialog.value = false; loadOrders()
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
} catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
|
||||
finally { refundSubmitting.value = false }
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ import { ref, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminPaymentAPI } from '@/api/admin/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import type { DashboardStats } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
@@ -110,7 +110,7 @@ async function loadDashboard() {
|
||||
const res = await adminPaymentAPI.getDashboard(days.value)
|
||||
stats.value = res.data
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminPaymentAPI } from '@/api/admin/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import adminAPI from '@/api/admin'
|
||||
import type { SubscriptionPlan } from '@/types/payment'
|
||||
import type { AdminGroup } from '@/types'
|
||||
@@ -150,7 +150,7 @@ async function loadPlans() {
|
||||
: (p.features || []),
|
||||
}))
|
||||
}
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
|
||||
finally { plansLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ async function toggleForSale(plan: SubscriptionPlan) {
|
||||
await adminPaymentAPI.updatePlan(plan.id, { for_sale: !plan.for_sale })
|
||||
plan.for_sale = !plan.for_sale
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ function confirmDeletePlan(plan: SubscriptionPlan) { deletingPlanId.value = plan
|
||||
async function handleDeletePlan() {
|
||||
if (!deletingPlanId.value) return
|
||||
try { await adminPaymentAPI.deletePlan(deletingPlanId.value); appStore.showSuccess(t('common.deleted')); showDeletePlanDialog.value = false; loadPlans() }
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
|
||||
}
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
@@ -39,7 +39,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { useAppStore } from '@/stores'
|
||||
import QRCode from 'qrcode'
|
||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||
@@ -167,7 +167,7 @@ async function handleCancel() {
|
||||
cleanup()
|
||||
router.push('/purchase')
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ import { usePaymentStore } from '@/stores/payment'
|
||||
import { useSubscriptionStore } from '@/stores/subscriptions'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { isMobileDevice } from '@/utils/device'
|
||||
import type { SubscriptionPlan, CheckoutInfoResponse, OrderType } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
@@ -610,7 +610,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
||||
} else if (apiErr.reason === 'CANCEL_RATE_LIMITED') {
|
||||
errorMessage.value = t('payment.errors.cancelRateLimited')
|
||||
} else {
|
||||
errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
errorMessage.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.result.failed'))
|
||||
}
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
@@ -648,7 +648,7 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
} catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
|
||||
finally { loading.value = false }
|
||||
// Fetch active subscriptions (uses cache, non-blocking)
|
||||
subscriptionStore.fetchActiveSubscriptions().catch(() => {})
|
||||
|
||||
@@ -99,7 +99,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { isMobileDevice } from '@/utils/device'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import type { Stripe, StripeElements } from '@stripe/stripe-js'
|
||||
@@ -167,7 +167,7 @@ onMounted(async () => {
|
||||
mountPaymentElement(stripe, clientSecret)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
|
||||
initError.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.stripeLoadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -248,7 +248,7 @@ async function handleGenericPay() {
|
||||
scheduleClose()
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
stripeError.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
stripeError.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.result.failed'))
|
||||
} finally {
|
||||
stripeSubmitting.value = false
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { isMobileDevice } from '@/utils/device'
|
||||
|
||||
interface StripeWithWechatPay {
|
||||
@@ -143,7 +143,7 @@ async function initStripe(clientSecret: string, publishableKey: string) {
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
|
||||
error.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.stripeLoadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
@@ -128,7 +128,7 @@ async function fetchOrders() {
|
||||
orders.value = res.data.items || []
|
||||
pagination.total = res.data.total || 0
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -148,7 +148,7 @@ async function confirmCancel() {
|
||||
cancelTargetId.value = null
|
||||
await fetchOrders()
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
@@ -166,7 +166,7 @@ async function confirmRefund() {
|
||||
refundReason.value = ''
|
||||
await fetchOrders()
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user