fix(payment): audit fixes for alipay/wxpay/stripe payment providers

Backend:
- Extract YuanToFen/FenToYuan to payment/amount.go using shopspring/decimal
- Require alipay publicKey in config validation
- Fix wxpay webhook response to return JSON per V3 spec
- Remove wxpay certSerial fallback to publicKeyId
- Define magic strings as named constants in wxpay/alipay providers
- Add slog warning for wxpay H5→Native payment downgrade
- Make EncryptionKey validation return error on invalid (non-empty) key
- Make decryptConfig propagate errors instead of returning nil
- Add idempotency check in doBalance to prevent stuck FAILED retries

Frontend:
- Fix dashboard currency symbol from $ to ¥
- Fix AdminPaymentPlansView any type to proper SubscriptionPlan type
- Make quick amount buttons follow selected payment method limits
- Center help image with larger height and text below
This commit is contained in:
erio
2026-04-08 17:11:32 +08:00
parent 93a43950fe
commit be82609939
13 changed files with 157 additions and 94 deletions
+4 -1
View File
@@ -210,7 +210,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
channelHandler := admin.NewChannelHandler(channelService, billingService)
registry := payment.ProvideRegistry()
encryptionKey := payment.ProvideEncryptionKey(configConfig)
encryptionKey, err := payment.ProvideEncryptionKey(configConfig)
if err != nil {
return nil, err
}
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey)
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService)
@@ -75,7 +75,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
provider, err := h.registry.GetProviderByKey(providerKey)
if err != nil {
slog.Warn("[Payment Webhook] provider not registered", "provider", providerKey, "error", err)
c.String(http.StatusOK, successResponse(providerKey))
writeSuccessResponse(c, providerKey)
return
}
@@ -98,7 +98,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
// nil notification means irrelevant event (e.g. Stripe non-payment event); return success.
if notification == nil {
c.String(http.StatusOK, successResponse(providerKey))
writeSuccessResponse(c, providerKey)
return
}
@@ -108,15 +108,25 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
return
}
c.String(http.StatusOK, successResponse(providerKey))
writeSuccessResponse(c, providerKey)
}
// successResponse returns the provider-specific success response string.
func successResponse(providerKey string) string {
// wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook.
type wxpaySuccessResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
// 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: "成功"})
case payment.TypeStripe:
return ""
c.String(http.StatusOK, "")
default:
return "success"
c.String(http.StatusOK, "success")
}
}
+24
View File
@@ -0,0 +1,24 @@
package payment
import (
"fmt"
"github.com/shopspring/decimal"
)
const centsPerYuan = 100
// YuanToFen converts a CNY yuan string (e.g. "10.50") to fen (int64).
// Uses shopspring/decimal for precision.
func YuanToFen(yuanStr string) (int64, error) {
d, err := decimal.NewFromString(yuanStr)
if err != nil {
return 0, fmt.Errorf("invalid amount: %s", yuanStr)
}
return d.Mul(decimal.NewFromInt(centsPerYuan)).IntPart(), nil
}
// FenToYuan converts fen (int64) to yuan as a float64 for interface compatibility.
func FenToYuan(fen int64) float64 {
return decimal.NewFromInt(fen).Div(decimal.NewFromInt(centsPerYuan)).InexactFloat64()
}
+15 -7
View File
@@ -19,6 +19,13 @@ const (
alipayProductCodeWapPay = "QUICK_WAP_WAY"
)
// Alipay response constants.
const (
alipayFundChangeYes = "Y"
alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST"
alipayRefundSuffix = "-refund"
)
// Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK.
type Alipay struct {
instanceID string
@@ -56,10 +63,11 @@ func (a *Alipay) getClient() (*alipay.Client, error) {
if pubKey == "" {
pubKey = a.config["alipayPublicKey"]
}
if pubKey != "" {
if err := client.LoadAliPayPublicKey(pubKey); err != nil {
return nil, fmt.Errorf("alipay load public key: %w", err)
}
if pubKey == "" {
return nil, fmt.Errorf("alipay config missing required key: publicKey (or alipayPublicKey)")
}
if err := client.LoadAliPayPublicKey(pubKey); err != nil {
return nil, fmt.Errorf("alipay load public key: %w", err)
}
a.client = client
return a.client, nil
@@ -225,13 +233,13 @@ func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*paymen
}
refundStatus := payment.ProviderStatusPending
if result.FundChange == "Y" {
if result.FundChange == alipayFundChangeYes {
refundStatus = payment.ProviderStatusSuccess
}
refundID := result.TradeNo
if refundID == "" {
refundID = req.OrderID + "-refund"
refundID = req.OrderID + alipayRefundSuffix
}
return &payment.RefundResponse{
@@ -261,7 +269,7 @@ func isTradeNotExist(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "ACQ.TRADE_NOT_EXIST")
return strings.Contains(err.Error(), alipayErrTradeNotExist)
}
// Ensure interface compliance.
+4 -21
View File
@@ -4,8 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"sync"
@@ -19,7 +17,6 @@ const (
stripeCurrency = "cny"
stripeEventPaymentSuccess = "payment_intent.succeeded"
stripeEventPaymentFailed = "payment_intent.payment_failed"
stripeCentsPerYuan = 100
)
// Stripe implements the payment.CancelableProvider interface for Stripe payments.
@@ -63,20 +60,6 @@ func (s *Stripe) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeStripe}
}
// centsToYuan converts an amount in cents (int64) to yuan (float64).
func centsToYuan(cents int64) float64 {
return float64(cents) / stripeCentsPerYuan
}
// yuanToCents converts a CNY yuan string to cents (int64).
func yuanToCents(amountStr string) (int64, error) {
amount, err := strconv.ParseFloat(amountStr, 64)
if err != nil {
return 0, fmt.Errorf("invalid amount: %s", amountStr)
}
return int64(math.Round(amount * 100)), nil
}
// stripePaymentMethodTypes maps our PaymentType to Stripe payment_method_types.
var stripePaymentMethodTypes = map[string][]string{
payment.TypeCard: {"card"},
@@ -89,7 +72,7 @@ var stripePaymentMethodTypes = map[string][]string{
func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
s.ensureInit()
amountInCents, err := yuanToCents(req.Amount)
amountInCents, err := payment.YuanToFen(req.Amount)
if err != nil {
return nil, fmt.Errorf("stripe create payment: %w", err)
}
@@ -153,7 +136,7 @@ func (s *Stripe) QueryOrder(ctx context.Context, tradeNo string) (*payment.Query
return &payment.QueryOrderResponse{
TradeNo: pi.ID,
Status: status,
Amount: centsToYuan(pi.Amount),
Amount: payment.FenToYuan(pi.Amount),
}, nil
}
@@ -194,7 +177,7 @@ func parseStripePaymentIntent(event *stripe.Event, status string, rawBody string
return &payment.PaymentNotification{
TradeNo: pi.ID,
OrderID: pi.Metadata["orderId"],
Amount: centsToYuan(pi.Amount),
Amount: payment.FenToYuan(pi.Amount),
Status: status,
RawData: rawBody,
}, nil
@@ -204,7 +187,7 @@ func parseStripePaymentIntent(event *stripe.Event, status string, rawBody string
func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
s.ensureInit()
amountInCents, err := yuanToCents(req.Amount)
amountInCents, err := payment.YuanToFen(req.Amount)
if err != nil {
return nil, fmt.Errorf("stripe refund: %w", err)
}
+32 -26
View File
@@ -6,9 +6,8 @@ import (
"crypto/rsa"
"fmt"
"io"
"math"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -27,9 +26,26 @@ import (
// WeChat Pay constants.
const (
wxpayCurrency = "CNY"
wxpayH5Type = "Wap"
wxpayFenPerYuan = 100
wxpayCurrency = "CNY"
wxpayH5Type = "Wap"
)
// WeChat Pay trade states.
const (
wxpayTradeStateSuccess = "SUCCESS"
wxpayTradeStateRefund = "REFUND"
wxpayTradeStateClosed = "CLOSED"
wxpayTradeStatePayError = "PAYERROR"
)
// WeChat Pay notification event types.
const (
wxpayEventTransactionSuccess = "TRANSACTION.SUCCESS"
)
// WeChat Pay error codes.
const (
wxpayErrNoAuth = "NO_AUTH"
)
type Wxpay struct {
@@ -78,9 +94,6 @@ func (w *Wxpay) ensureClient() (*core.Client, error) {
return nil, err
}
certSerial := w.config["certSerial"]
if certSerial == "" {
certSerial = w.config["publicKeyId"]
}
verifier := verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey)
client, err := core.NewClient(context.Background(),
option.WithMerchantCredential(w.config["mchId"], certSerial, privateKey),
@@ -109,14 +122,6 @@ func (w *Wxpay) loadKeyPair() (*rsa.PrivateKey, *rsa.PublicKey, error) {
return privateKey, publicKey, nil
}
func yuanToFen(s string) (int64, error) {
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, fmt.Errorf("invalid amount: %s", s)
}
return int64(math.Round(f * 100)), nil
}
func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
client, err := w.ensureClient()
if err != nil {
@@ -129,7 +134,7 @@ func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequ
if notifyURL == "" {
return nil, fmt.Errorf("wxpay notifyUrl is required")
}
totalFen, err := yuanToFen(req.Amount)
totalFen, err := payment.YuanToFen(req.Amount)
if err != nil {
return nil, fmt.Errorf("wxpay create payment: %w", err)
}
@@ -138,9 +143,10 @@ func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequ
if err == nil {
return resp, nil
}
if !strings.Contains(err.Error(), "NO_AUTH") {
if !strings.Contains(err.Error(), wxpayErrNoAuth) {
return nil, err
}
slog.Warn("wxpay H5 payment not authorized, falling back to native", "order", req.OrderID)
}
return w.createOrder(ctx, client, req, notifyURL, totalFen, false)
}
@@ -201,11 +207,11 @@ func wxSV(s *string) string {
func mapWxState(s string) string {
switch s {
case "SUCCESS":
case wxpayTradeStateSuccess:
return payment.ProviderStatusPaid
case "REFUND":
case wxpayTradeStateRefund:
return payment.ProviderStatusRefunded
case "CLOSED", "PAYERROR":
case wxpayTradeStateClosed, wxpayTradeStatePayError:
return payment.ProviderStatusFailed
default:
return payment.ProviderStatusPending
@@ -226,7 +232,7 @@ func (w *Wxpay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryO
}
var amt float64
if tx.Amount != nil && tx.Amount.Total != nil {
amt = float64(*tx.Amount.Total) / wxpayFenPerYuan
amt = payment.FenToYuan(*tx.Amount.Total)
}
id := tradeNo
if tx.TransactionId != nil {
@@ -255,15 +261,15 @@ func (w *Wxpay) VerifyNotification(ctx context.Context, rawBody string, headers
if err != nil {
return nil, fmt.Errorf("wxpay verify notification: %w", err)
}
if nr.EventType != "TRANSACTION.SUCCESS" {
if nr.EventType != wxpayEventTransactionSuccess {
return nil, nil
}
var amt float64
if tx.Amount != nil && tx.Amount.Total != nil {
amt = float64(*tx.Amount.Total) / wxpayFenPerYuan
amt = payment.FenToYuan(*tx.Amount.Total)
}
st := payment.ProviderStatusFailed
if wxSV(tx.TradeState) == "SUCCESS" {
if wxSV(tx.TradeState) == wxpayTradeStateSuccess {
st = payment.ProviderStatusSuccess
}
return &payment.PaymentNotification{
@@ -277,7 +283,7 @@ func (w *Wxpay) Refund(ctx context.Context, req payment.RefundRequest) (*payment
if err != nil {
return nil, err
}
rf, err := yuanToFen(req.Amount)
rf, err := payment.YuanToFen(req.Amount)
if err != nil {
return nil, fmt.Errorf("wxpay refund amount: %w", err)
}
+10 -7
View File
@@ -2,6 +2,7 @@ package payment
import (
"encoding/hex"
"fmt"
"log/slog"
dbent "github.com/Wei-Shaw/sub2api/ent"
@@ -14,20 +15,22 @@ import (
type EncryptionKey []byte
// ProvideEncryptionKey derives the payment encryption key from the TOTP encryption key in config.
func ProvideEncryptionKey(cfg *config.Config) EncryptionKey {
// When the key is empty, nil is returned (payment features that need encryption will be disabled).
// When the key is non-empty but invalid (bad hex or wrong length), an error is returned
// to prevent startup with a misconfigured encryption key.
func ProvideEncryptionKey(cfg *config.Config) (EncryptionKey, error) {
if cfg.Totp.EncryptionKey == "" {
return nil
slog.Warn("payment encryption key not configured — encrypted payment config will be unavailable")
return nil, nil
}
key, err := hex.DecodeString(cfg.Totp.EncryptionKey)
if err != nil {
slog.Error("invalid payment encryption key", "error", err)
return nil
return nil, fmt.Errorf("invalid payment encryption key (hex decode): %w", err)
}
if len(key) != 32 {
slog.Error("payment encryption key must be 32 bytes", "got", len(key))
return nil
return nil, fmt.Errorf("payment encryption key must be 32 bytes, got %d", len(key))
}
return EncryptionKey(key)
return EncryptionKey(key), nil
}
// ProvideRegistry creates an empty payment provider registry.
@@ -49,13 +49,16 @@ func (s *PaymentConfigService) ListProviderInstancesWithConfig(ctx context.Conte
Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, SortOrder: inst.SortOrder,
PaymentMode: inst.PaymentMode,
}
resp.Config = s.decryptAndMaskConfig(inst.Config)
resp.Config, err = s.decryptAndMaskConfig(inst.Config)
if err != nil {
return nil, fmt.Errorf("decrypt config for instance %d: %w", inst.ID, err)
}
result = append(result, resp)
}
return result, nil
}
func (s *PaymentConfigService) decryptAndMaskConfig(encrypted string) map[string]string {
func (s *PaymentConfigService) decryptAndMaskConfig(encrypted string) (map[string]string, error) {
return s.decryptConfig(encrypted)
}
@@ -201,7 +204,10 @@ func (s *PaymentConfigService) mergeConfig(ctx context.Context, id int64, newCon
if err != nil {
return nil, fmt.Errorf("load existing provider: %w", err)
}
existing := s.decryptConfig(inst.Config)
existing, err := s.decryptConfig(inst.Config)
if err != nil {
return nil, fmt.Errorf("decrypt existing config for instance %d: %w", id, err)
}
if existing == nil {
return newConfig, nil
}
@@ -211,19 +217,19 @@ func (s *PaymentConfigService) mergeConfig(ctx context.Context, id int64, newCon
return existing, nil
}
func (s *PaymentConfigService) decryptConfig(encrypted string) map[string]string {
func (s *PaymentConfigService) decryptConfig(encrypted string) (map[string]string, error) {
if encrypted == "" {
return nil
return nil, nil
}
decrypted, err := payment.Decrypt(encrypted, s.encryptionKey)
if err != nil {
return nil
return nil, fmt.Errorf("decrypt config: %w", err)
}
var raw map[string]string
if err := json.Unmarshal([]byte(decrypted), &raw); err != nil {
return nil
return nil, fmt.Errorf("unmarshal decrypted config: %w", err)
}
return raw
return raw, nil
}
func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error {
@@ -242,8 +242,8 @@ func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) stri
if err != nil || len(instances) == 0 {
return ""
}
cfg := s.decryptConfig(instances[0].Config)
if cfg == nil {
cfg, err := s.decryptConfig(instances[0].Config)
if err != nil || cfg == nil {
return ""
}
return cfg[payment.ConfigKeyPublishableKey]
@@ -147,19 +147,33 @@ func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int6
}
func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) error {
rc := &RedeemCode{Code: o.RechargeCode, Type: RedeemTypeBalance, Value: o.Amount, Status: StatusUnused}
if err := s.redeemService.CreateCode(ctx, rc); err != nil {
return fmt.Errorf("create redeem code: %w", err)
// Idempotency: check if redeem code already exists (from a previous partial run)
existing, _ := s.redeemService.GetByCode(ctx, o.RechargeCode)
if existing != nil {
if existing.IsUsed() {
// Code already created and redeemed — just mark completed
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
}
// Code exists but unused — skip creation, proceed to redeem
} else {
rc := &RedeemCode{Code: o.RechargeCode, Type: RedeemTypeBalance, Value: o.Amount, Status: StatusUnused}
if err := s.redeemService.CreateCode(ctx, rc); err != nil {
return fmt.Errorf("create redeem code: %w", err)
}
}
if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil {
return fmt.Errorf("redeem balance: %w", err)
}
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
}
func (s *PaymentService) markCompleted(ctx context.Context, o *dbent.PaymentOrder, auditAction string) error {
now := time.Now()
_, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.StatusEQ(OrderStatusRecharging)).SetStatus(OrderStatusCompleted).SetCompletedAt(now).Save(ctx)
if err != nil {
return fmt.Errorf("mark completed: %w", err)
}
s.writeAuditLog(ctx, o.ID, "RECHARGE_SUCCESS", "system", map[string]any{"rechargeCode": o.RechargeCode, "amount": o.Amount})
s.writeAuditLog(ctx, o.ID, auditAction, "system", map[string]any{"rechargeCode": o.RechargeCode, "amount": o.Amount})
return nil
}
@@ -42,7 +42,7 @@
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + method.type, method.type) }}</span>
</div>
<div class="text-right">
<span class="text-sm font-medium text-gray-900 dark:text-white">${{ method.amount.toFixed(2) }}</span>
<span class="text-sm font-medium text-gray-900 dark:text-white">&yen;{{ method.amount.toFixed(2) }}</span>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">({{ method.count }})</span>
</div>
</div>
@@ -57,7 +57,7 @@
<span :class="['flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold', rankClass(idx)]">{{ idx + 1 }}</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
</div>
<span class="text-sm font-medium text-gray-900 dark:text-white">${{ user.amount.toFixed(2) }}</span>
<span class="text-sm font-medium text-gray-900 dark:text-white">&yen;{{ user.amount.toFixed(2) }}</span>
</div>
</div>
</div>
@@ -219,7 +219,7 @@ async function loadPlans() {
try {
const res = await adminPaymentAPI.getPlans()
// Backend returns features as newline-separated string; parse to array
plans.value = (res.data || []).map((p: any) => ({
plans.value = (res.data || []).map((p: Omit<SubscriptionPlan, 'features'> & { features: string | string[] }) => ({
...p,
features: typeof p.features === 'string'
? p.features.split('\n').map((f: string) => f.trim()).filter(Boolean)
+14 -8
View File
@@ -30,8 +30,8 @@
<AmountInput
v-model="amount"
:amounts="[10, 20, 50, 100, 200, 500, 1000, 2000, 5000]"
:min="globalMinAmount"
:max="globalMaxAmount"
:min="activeMinAmount"
:max="activeMaxAmount"
/>
<p v-if="amountError" class="mt-2 text-xs text-amber-600 dark:text-amber-300">{{ amountError }}</p>
</div>
@@ -81,11 +81,11 @@
</div>
</template>
<div v-if="checkout.help_text || checkout.help_image_url" class="card p-4">
<div :class="checkout.help_image_url && checkout.help_text ? 'flex items-start gap-4' : ''">
<div class="flex flex-col items-center gap-3">
<img v-if="checkout.help_image_url" :src="checkout.help_image_url" alt=""
class="h-24 w-24 flex-shrink-0 cursor-pointer rounded-lg object-contain transition-opacity hover:opacity-80"
class="h-40 max-w-full cursor-pointer rounded-lg object-contain transition-opacity hover:opacity-80"
@click="previewImage = checkout.help_image_url" />
<p v-if="checkout.help_text" class="text-sm text-gray-500 dark:text-gray-400">{{ checkout.help_text }}</p>
<p v-if="checkout.help_text" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ checkout.help_text }}</p>
</div>
</div>
</template>
@@ -213,9 +213,15 @@ function amountFitsMethod(amt: number, methodType: string): boolean {
return true
}
// Global range for AmountInput quick buttons (precomputed by backend)
const globalMinAmount = computed(() => checkout.value.global_min)
const globalMaxAmount = computed(() => checkout.value.global_max)
// Amount range: use selected method's limits when available, fallback to global
const activeMinAmount = computed(() => {
const ml = selectedLimit.value
return ml?.single_min && ml.single_min > 0 ? ml.single_min : checkout.value.global_min
})
const activeMaxAmount = computed(() => {
const ml = selectedLimit.value
return ml?.single_max && ml.single_max > 0 ? ml.single_max : checkout.value.global_max
})
// Selected method's limits (for validation and error messages)
const selectedLimit = computed(() => checkout.value.methods[selectedMethod.value])