2026-04-10 21:08:51 +08:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
"errors"
|
2026-04-10 21:08:51 +08:00
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"math"
|
2026-04-20 23:34:57 +08:00
|
|
|
"net/url"
|
2026-04-10 21:08:51 +08:00
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
|
|
|
"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"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// --- Order Creation ---
|
|
|
|
|
|
|
|
|
|
func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*CreateOrderResponse, error) {
|
|
|
|
|
if req.OrderType == "" {
|
|
|
|
|
req.OrderType = payment.OrderTypeBalance
|
|
|
|
|
}
|
2026-04-20 20:19:23 +08:00
|
|
|
if normalized := NormalizeVisibleMethod(req.PaymentType); normalized != "" {
|
|
|
|
|
req.PaymentType = normalized
|
|
|
|
|
}
|
2026-04-10 21:08:51 +08:00
|
|
|
cfg, err := s.configService.GetPaymentConfig(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("get payment config: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if !cfg.Enabled {
|
|
|
|
|
return nil, infraerrors.Forbidden("PAYMENT_DISABLED", "payment system is disabled")
|
|
|
|
|
}
|
|
|
|
|
plan, err := s.validateOrderInput(ctx, req, cfg)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := s.checkCancelRateLimit(ctx, req.UserID, cfg); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
user, err := s.userRepo.GetByID(ctx, req.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("get user: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if user.Status != payment.EntityStatusActive {
|
|
|
|
|
return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled")
|
|
|
|
|
}
|
2026-04-15 00:14:57 +08:00
|
|
|
orderAmount := req.Amount
|
|
|
|
|
limitAmount := req.Amount
|
2026-04-10 21:08:51 +08:00
|
|
|
if plan != nil {
|
2026-04-15 00:14:57 +08:00
|
|
|
orderAmount = plan.Price
|
|
|
|
|
limitAmount = plan.Price
|
|
|
|
|
} else if req.OrderType == payment.OrderTypeBalance {
|
|
|
|
|
orderAmount = calculateCreditedBalance(req.Amount, cfg.BalanceRechargeMultiplier)
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
2026-04-15 01:04:01 +08:00
|
|
|
feeRate := cfg.RechargeFeeRate
|
2026-04-15 00:14:57 +08:00
|
|
|
payAmountStr := payment.CalculatePayAmount(limitAmount, feeRate)
|
2026-04-10 21:08:51 +08:00
|
|
|
payAmount, _ := strconv.ParseFloat(payAmountStr, 64)
|
2026-04-20 23:34:57 +08:00
|
|
|
sel, err := s.selectCreateOrderInstance(ctx, req, cfg, payAmount)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := s.validateSelectedCreateOrderInstance(ctx, req, sel); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
oauthResp, err := s.maybeBuildWeChatOAuthRequiredResponseForSelection(ctx, req, limitAmount, payAmount, feeRate, sel)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if oauthResp != nil {
|
|
|
|
|
return oauthResp, nil
|
|
|
|
|
}
|
2026-04-21 12:41:27 +08:00
|
|
|
order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount, sel)
|
2026-04-10 21:08:51 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-04-20 23:34:57 +08:00
|
|
|
resp, err := s.invokeProvider(ctx, order, req, cfg, limitAmount, payAmountStr, payAmount, plan, sel)
|
2026-04-10 21:08:51 +08:00
|
|
|
if err != nil {
|
|
|
|
|
_, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID).
|
|
|
|
|
SetStatus(OrderStatusFailed).
|
|
|
|
|
Save(ctx)
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return resp, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PaymentService) validateOrderInput(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig) (*dbent.SubscriptionPlan, error) {
|
|
|
|
|
if req.OrderType == payment.OrderTypeBalance && cfg.BalanceDisabled {
|
|
|
|
|
return nil, infraerrors.Forbidden("BALANCE_PAYMENT_DISABLED", "balance recharge has been disabled")
|
|
|
|
|
}
|
|
|
|
|
if req.OrderType == payment.OrderTypeSubscription {
|
|
|
|
|
return s.validateSubOrder(ctx, req)
|
|
|
|
|
}
|
|
|
|
|
if math.IsNaN(req.Amount) || math.IsInf(req.Amount, 0) || req.Amount <= 0 {
|
|
|
|
|
return nil, infraerrors.BadRequest("INVALID_AMOUNT", "amount must be a positive number")
|
|
|
|
|
}
|
|
|
|
|
if (cfg.MinAmount > 0 && req.Amount < cfg.MinAmount) || (cfg.MaxAmount > 0 && req.Amount > cfg.MaxAmount) {
|
|
|
|
|
return nil, infraerrors.BadRequest("INVALID_AMOUNT", "amount out of range").
|
|
|
|
|
WithMetadata(map[string]string{"min": fmt.Sprintf("%.2f", cfg.MinAmount), "max": fmt.Sprintf("%.2f", cfg.MaxAmount)})
|
|
|
|
|
}
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PaymentService) validateSubOrder(ctx context.Context, req CreateOrderRequest) (*dbent.SubscriptionPlan, error) {
|
|
|
|
|
if req.PlanID == 0 {
|
|
|
|
|
return nil, infraerrors.BadRequest("INVALID_INPUT", "subscription order requires a plan")
|
|
|
|
|
}
|
|
|
|
|
plan, err := s.configService.GetPlan(ctx, req.PlanID)
|
|
|
|
|
if err != nil || !plan.ForSale {
|
|
|
|
|
return nil, infraerrors.NotFound("PLAN_NOT_AVAILABLE", "plan not found or not for sale")
|
|
|
|
|
}
|
|
|
|
|
group, err := s.groupRepo.GetByID(ctx, plan.GroupID)
|
|
|
|
|
if err != nil || group.Status != payment.EntityStatusActive {
|
|
|
|
|
return nil, infraerrors.NotFound("GROUP_NOT_FOUND", "subscription group is no longer available")
|
|
|
|
|
}
|
|
|
|
|
if !group.IsSubscriptionType() {
|
|
|
|
|
return nil, infraerrors.BadRequest("GROUP_TYPE_MISMATCH", "group is not a subscription type")
|
|
|
|
|
}
|
|
|
|
|
return plan, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:41:27 +08:00
|
|
|
func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderRequest, user *User, plan *dbent.SubscriptionPlan, cfg *PaymentConfig, orderAmount, limitAmount, feeRate, payAmount float64, sel *payment.InstanceSelection) (*dbent.PaymentOrder, error) {
|
2026-04-10 21:08:51 +08:00
|
|
|
tx, err := s.entClient.Tx(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
|
if err := s.checkPendingLimit(ctx, tx, req.UserID, cfg.MaxPendingOrders); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-04-15 00:14:57 +08:00
|
|
|
if err := s.checkDailyLimit(ctx, tx, req.UserID, limitAmount, cfg.DailyLimit); err != nil {
|
2026-04-10 21:08:51 +08:00
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
tm := cfg.OrderTimeoutMin
|
|
|
|
|
if tm <= 0 {
|
|
|
|
|
tm = defaultOrderTimeoutMin
|
|
|
|
|
}
|
|
|
|
|
exp := time.Now().Add(time.Duration(tm) * time.Minute)
|
2026-04-21 12:57:35 +08:00
|
|
|
providerSnapshot := buildPaymentOrderProviderSnapshot(sel, req)
|
2026-04-21 12:41:27 +08:00
|
|
|
selectedInstanceID := ""
|
|
|
|
|
selectedProviderKey := ""
|
|
|
|
|
if sel != nil {
|
|
|
|
|
selectedInstanceID = strings.TrimSpace(sel.InstanceID)
|
|
|
|
|
selectedProviderKey = strings.TrimSpace(sel.ProviderKey)
|
|
|
|
|
}
|
2026-04-10 21:08:51 +08:00
|
|
|
b := tx.PaymentOrder.Create().
|
|
|
|
|
SetUserID(req.UserID).
|
|
|
|
|
SetUserEmail(user.Email).
|
|
|
|
|
SetUserName(user.Username).
|
|
|
|
|
SetNillableUserNotes(psNilIfEmpty(user.Notes)).
|
2026-04-15 00:14:57 +08:00
|
|
|
SetAmount(orderAmount).
|
2026-04-10 21:08:51 +08:00
|
|
|
SetPayAmount(payAmount).
|
|
|
|
|
SetFeeRate(feeRate).
|
|
|
|
|
SetRechargeCode("").
|
|
|
|
|
SetOutTradeNo(generateOutTradeNo()).
|
|
|
|
|
SetPaymentType(req.PaymentType).
|
|
|
|
|
SetPaymentTradeNo("").
|
|
|
|
|
SetOrderType(req.OrderType).
|
|
|
|
|
SetStatus(OrderStatusPending).
|
|
|
|
|
SetExpiresAt(exp).
|
|
|
|
|
SetClientIP(req.ClientIP).
|
|
|
|
|
SetSrcHost(req.SrcHost)
|
|
|
|
|
if req.SrcURL != "" {
|
|
|
|
|
b.SetSrcURL(req.SrcURL)
|
|
|
|
|
}
|
2026-04-21 12:41:27 +08:00
|
|
|
if selectedInstanceID != "" {
|
|
|
|
|
b.SetProviderInstanceID(selectedInstanceID)
|
|
|
|
|
}
|
|
|
|
|
if selectedProviderKey != "" {
|
|
|
|
|
b.SetProviderKey(selectedProviderKey)
|
|
|
|
|
}
|
|
|
|
|
if providerSnapshot != nil {
|
|
|
|
|
b.SetProviderSnapshot(providerSnapshot)
|
|
|
|
|
}
|
2026-04-10 21:08:51 +08:00
|
|
|
if plan != nil {
|
|
|
|
|
b.SetPlanID(plan.ID).SetSubscriptionGroupID(plan.GroupID).SetSubscriptionDays(psComputeValidityDays(plan.ValidityDays, plan.ValidityUnit))
|
|
|
|
|
}
|
|
|
|
|
order, err := b.Save(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("create order: %w", err)
|
|
|
|
|
}
|
|
|
|
|
code := fmt.Sprintf("PAY-%d-%d", order.ID, time.Now().UnixNano()%100000)
|
|
|
|
|
order, err = tx.PaymentOrder.UpdateOneID(order.ID).SetRechargeCode(code).Save(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("set recharge code: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("commit order transaction: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return order, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, userID int64, max int) error {
|
|
|
|
|
if max <= 0 {
|
|
|
|
|
max = defaultMaxPendingOrders
|
|
|
|
|
}
|
|
|
|
|
c, err := tx.PaymentOrder.Query().Where(paymentorder.UserIDEQ(userID), paymentorder.StatusEQ(OrderStatusPending)).Count(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("count pending orders: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if c >= max {
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
return infraerrors.TooManyRequests("TOO_MANY_PENDING", "too_many_pending").
|
2026-04-10 21:08:51 +08:00
|
|
|
WithMetadata(map[string]string{"max": strconv.Itoa(max)})
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:57:35 +08:00
|
|
|
func buildPaymentOrderProviderSnapshot(sel *payment.InstanceSelection, req CreateOrderRequest) map[string]any {
|
2026-04-21 12:41:27 +08:00
|
|
|
if sel == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
snapshot := map[string]any{}
|
2026-04-21 12:57:35 +08:00
|
|
|
snapshot["schema_version"] = 2
|
2026-04-21 12:41:27 +08:00
|
|
|
|
|
|
|
|
instanceID := strings.TrimSpace(sel.InstanceID)
|
|
|
|
|
if instanceID != "" {
|
|
|
|
|
snapshot["provider_instance_id"] = instanceID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
providerKey := strings.TrimSpace(sel.ProviderKey)
|
|
|
|
|
if providerKey != "" {
|
|
|
|
|
snapshot["provider_key"] = providerKey
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
paymentMode := strings.TrimSpace(sel.PaymentMode)
|
|
|
|
|
if paymentMode != "" {
|
|
|
|
|
snapshot["payment_mode"] = paymentMode
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:57:35 +08:00
|
|
|
if providerKey == payment.TypeWxpay {
|
|
|
|
|
if merchantAppID := paymentOrderSnapshotWxpayAppID(sel, req); merchantAppID != "" {
|
|
|
|
|
snapshot["merchant_app_id"] = merchantAppID
|
|
|
|
|
}
|
|
|
|
|
if merchantID := strings.TrimSpace(sel.Config["mchId"]); merchantID != "" {
|
|
|
|
|
snapshot["merchant_id"] = merchantID
|
|
|
|
|
}
|
|
|
|
|
snapshot["currency"] = "CNY"
|
|
|
|
|
}
|
2026-04-21 13:35:54 +08:00
|
|
|
if providerKey == payment.TypeAlipay {
|
|
|
|
|
if merchantAppID := strings.TrimSpace(sel.Config["appId"]); merchantAppID != "" {
|
|
|
|
|
snapshot["merchant_app_id"] = merchantAppID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if providerKey == payment.TypeEasyPay {
|
|
|
|
|
if merchantID := strings.TrimSpace(sel.Config["pid"]); merchantID != "" {
|
|
|
|
|
snapshot["merchant_id"] = merchantID
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-21 12:57:35 +08:00
|
|
|
|
2026-04-21 12:41:27 +08:00
|
|
|
if len(snapshot) == 1 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return snapshot
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:57:35 +08:00
|
|
|
func paymentOrderSnapshotWxpayAppID(sel *payment.InstanceSelection, req CreateOrderRequest) string {
|
|
|
|
|
if sel == nil || strings.TrimSpace(sel.ProviderKey) != payment.TypeWxpay {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(req.OpenID) != "" {
|
|
|
|
|
return strings.TrimSpace(provider.ResolveWxpayJSAPIAppID(sel.Config))
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimSpace(sel.Config["appId"])
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 21:08:51 +08:00
|
|
|
func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, userID int64, amount, limit float64) error {
|
|
|
|
|
if limit <= 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
ts := psStartOfDayUTC(time.Now())
|
|
|
|
|
orders, err := tx.PaymentOrder.Query().Where(paymentorder.UserIDEQ(userID), paymentorder.StatusIn(OrderStatusPaid, OrderStatusRecharging, OrderStatusCompleted), paymentorder.PaidAtGTE(ts)).All(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("query daily usage: %w", err)
|
|
|
|
|
}
|
|
|
|
|
var used float64
|
|
|
|
|
for _, o := range orders {
|
2026-04-15 00:14:57 +08:00
|
|
|
if o.OrderType == payment.OrderTypeBalance {
|
|
|
|
|
used += o.PayAmount
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-04-10 21:08:51 +08:00
|
|
|
used += o.Amount
|
|
|
|
|
}
|
|
|
|
|
if used+amount > limit {
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
return infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily_limit_exceeded").
|
|
|
|
|
WithMetadata(map[string]string{"remaining": fmt.Sprintf("%.2f", math.Max(0, limit-used))})
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:34:57 +08:00
|
|
|
func (s *PaymentService) selectCreateOrderInstance(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig, payAmount float64) (*payment.InstanceSelection, error) {
|
2026-04-21 01:40:56 +08:00
|
|
|
selectCtx, err := s.prepareCreateOrderSelectionContext(ctx, req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
sel, err := s.loadBalancer.SelectInstance(selectCtx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount)
|
2026-04-10 21:08:51 +08:00
|
|
|
if err != nil {
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "method_not_configured").
|
|
|
|
|
WithMetadata(map[string]string{"payment_type": req.PaymentType})
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
if sel == nil {
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no_available_instance")
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
2026-04-20 23:34:57 +08:00
|
|
|
return sel, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 01:40:56 +08:00
|
|
|
func (s *PaymentService) prepareCreateOrderSelectionContext(ctx context.Context, req CreateOrderRequest) (context.Context, error) {
|
|
|
|
|
if !requestNeedsWeChatJSAPICompatibility(req) {
|
|
|
|
|
return ctx, nil
|
|
|
|
|
}
|
|
|
|
|
if !s.usesOfficialWxpayVisibleMethod(ctx) {
|
|
|
|
|
return ctx, nil
|
|
|
|
|
}
|
|
|
|
|
expectedAppID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return payment.WithWxpayJSAPIAppID(ctx, expectedAppID), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func requestNeedsWeChatJSAPICompatibility(req CreateOrderRequest) bool {
|
|
|
|
|
if payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return req.IsWeChatBrowser || strings.TrimSpace(req.OpenID) != ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PaymentService) usesOfficialWxpayVisibleMethod(ctx context.Context) bool {
|
2026-04-21 23:17:45 +08:00
|
|
|
if s == nil || s.configService == nil {
|
2026-04-21 01:40:56 +08:00
|
|
|
return false
|
|
|
|
|
}
|
2026-04-21 23:17:45 +08:00
|
|
|
inst, err := s.configService.resolveEnabledVisibleMethodInstance(ctx, payment.TypeWxpay)
|
2026-04-21 01:40:56 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-04-21 23:17:45 +08:00
|
|
|
if inst == nil {
|
2026-04-21 01:40:56 +08:00
|
|
|
return false
|
|
|
|
|
}
|
2026-04-21 23:17:45 +08:00
|
|
|
return inst.ProviderKey == payment.TypeWxpay
|
2026-04-21 01:40:56 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:34:57 +08:00
|
|
|
func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, limitAmount float64, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
|
2026-04-13 14:06:29 +08:00
|
|
|
prov, err := provider.CreateProvider(sel.ProviderKey, sel.InstanceID, sel.Config)
|
2026-04-10 21:08:51 +08:00
|
|
|
if err != nil {
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
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})
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
2026-04-15 01:43:37 +08:00
|
|
|
subject := s.buildPaymentSubject(plan, limitAmount, cfg)
|
2026-04-10 21:08:51 +08:00
|
|
|
outTradeNo := order.OutTradeNo
|
2026-04-21 14:10:30 +08:00
|
|
|
canonicalReturnURL, err := CanonicalizeReturnURL(req.ReturnURL, req.SrcHost)
|
2026-04-20 20:19:23 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
resumeToken := ""
|
|
|
|
|
if resume := s.paymentResume(); resume != nil {
|
2026-04-21 01:40:56 +08:00
|
|
|
if resume.isSigningConfigured() {
|
|
|
|
|
resumeToken, err = resume.CreateToken(ResumeTokenClaims{
|
|
|
|
|
OrderID: order.ID,
|
|
|
|
|
UserID: order.UserID,
|
|
|
|
|
ProviderInstanceID: sel.InstanceID,
|
|
|
|
|
ProviderKey: sel.ProviderKey,
|
|
|
|
|
PaymentType: req.PaymentType,
|
|
|
|
|
CanonicalReturnURL: canonicalReturnURL,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("create payment resume token: %w", err)
|
|
|
|
|
}
|
2026-04-20 20:19:23 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
providerReturnURL, err := buildPaymentReturnURL(canonicalReturnURL, order.ID, resumeToken)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-04-20 23:34:57 +08:00
|
|
|
providerReq := buildProviderCreatePaymentRequest(CreateOrderRequest{
|
|
|
|
|
PaymentType: req.PaymentType,
|
|
|
|
|
OpenID: req.OpenID,
|
|
|
|
|
ClientIP: req.ClientIP,
|
|
|
|
|
IsMobile: req.IsMobile,
|
|
|
|
|
ReturnURL: providerReturnURL,
|
|
|
|
|
}, sel, outTradeNo, payAmountStr, subject)
|
|
|
|
|
pr, err := prov.CreatePayment(ctx, providerReq)
|
2026-04-10 21:08:51 +08:00
|
|
|
if err != nil {
|
2026-04-13 14:06:29 +08:00
|
|
|
slog.Error("[PaymentService] CreatePayment failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
if appErr := new(infraerrors.ApplicationError); errors.As(err, &appErr) {
|
|
|
|
|
return nil, appErr
|
|
|
|
|
}
|
2026-04-20 23:34:57 +08:00
|
|
|
return nil, classifyCreatePaymentError(req, sel.ProviderKey, err)
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
2026-04-20 20:47:14 +08:00
|
|
|
_, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).
|
|
|
|
|
SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).
|
|
|
|
|
SetNillablePayURL(psNilIfEmpty(pr.PayURL)).
|
|
|
|
|
SetNillableQrCode(psNilIfEmpty(pr.QRCode)).
|
|
|
|
|
SetNillableProviderInstanceID(psNilIfEmpty(sel.InstanceID)).
|
|
|
|
|
SetNillableProviderKey(psNilIfEmpty(sel.ProviderKey)).
|
|
|
|
|
Save(ctx)
|
2026-04-10 21:08:51 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("update order with payment details: %w", err)
|
|
|
|
|
}
|
2026-04-15 00:14:57 +08:00
|
|
|
s.writeAuditLog(ctx, order.ID, "ORDER_CREATED", fmt.Sprintf("user:%d", req.UserID), map[string]any{
|
|
|
|
|
"paymentAmount": req.Amount,
|
|
|
|
|
"creditedAmount": order.Amount,
|
|
|
|
|
"payAmount": order.PayAmount,
|
|
|
|
|
"paymentType": req.PaymentType,
|
|
|
|
|
"orderType": req.OrderType,
|
2026-04-20 20:19:23 +08:00
|
|
|
"paymentSource": NormalizePaymentSource(req.PaymentSource),
|
2026-04-15 00:14:57 +08:00
|
|
|
})
|
2026-04-20 23:34:57 +08:00
|
|
|
resultType := pr.ResultType
|
|
|
|
|
if resultType == "" {
|
|
|
|
|
resultType = payment.CreatePaymentResultOrderCreated
|
|
|
|
|
}
|
|
|
|
|
resp := buildCreateOrderResponse(order, req, payAmount, sel, pr, resultType)
|
|
|
|
|
resp.ResumeToken = resumeToken
|
|
|
|
|
return resp, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func buildProviderCreatePaymentRequest(req CreateOrderRequest, sel *payment.InstanceSelection, orderID, amount, subject string) payment.CreatePaymentRequest {
|
|
|
|
|
return payment.CreatePaymentRequest{
|
|
|
|
|
OrderID: orderID,
|
|
|
|
|
Amount: amount,
|
|
|
|
|
PaymentType: req.PaymentType,
|
|
|
|
|
Subject: subject,
|
|
|
|
|
ReturnURL: req.ReturnURL,
|
|
|
|
|
OpenID: strings.TrimSpace(req.OpenID),
|
|
|
|
|
ClientIP: req.ClientIP,
|
|
|
|
|
IsMobile: req.IsMobile,
|
|
|
|
|
InstanceSubMethods: selectedInstanceSupportedTypes(sel),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func selectedInstanceSupportedTypes(sel *payment.InstanceSelection) string {
|
|
|
|
|
if sel == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return sel.SupportedTypes
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:43:37 +08:00
|
|
|
func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limitAmount float64, cfg *PaymentConfig) string {
|
2026-04-10 21:08:51 +08:00
|
|
|
if plan != nil {
|
|
|
|
|
if plan.ProductName != "" {
|
|
|
|
|
return plan.ProductName
|
|
|
|
|
}
|
|
|
|
|
return "Sub2API Subscription " + plan.Name
|
|
|
|
|
}
|
2026-04-15 01:43:37 +08:00
|
|
|
amountStr := strconv.FormatFloat(limitAmount, 'f', 2, 64)
|
2026-04-10 21:08:51 +08:00
|
|
|
pf := strings.TrimSpace(cfg.ProductNamePrefix)
|
|
|
|
|
sf := strings.TrimSpace(cfg.ProductNameSuffix)
|
|
|
|
|
if pf != "" || sf != "" {
|
2026-04-15 01:43:37 +08:00
|
|
|
return strings.TrimSpace(pf + " " + amountStr + " " + sf)
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
2026-04-15 01:43:37 +08:00
|
|
|
return "Sub2API " + amountStr + " CNY"
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:34:57 +08:00
|
|
|
func (s *PaymentService) maybeBuildWeChatOAuthRequiredResponse(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64) (*CreateOrderResponse, error) {
|
|
|
|
|
return s.maybeBuildWeChatOAuthRequiredResponseForSelection(ctx, req, amount, payAmount, feeRate, nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PaymentService) maybeBuildWeChatOAuthRequiredResponseForSelection(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
|
|
|
|
|
if sel != nil && sel.ProviderKey != "" && sel.ProviderKey != payment.TypeWxpay {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(req.OpenID) != "" || !req.IsWeChatBrowser || payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
return s.buildWeChatOAuthRequiredResponse(ctx, req, amount, payAmount, feeRate)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PaymentService) buildWeChatOAuthRequiredResponse(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64) (*CreateOrderResponse, error) {
|
|
|
|
|
appID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
authorizeURL, err := buildWeChatPaymentOAuthStartURL(req, "snsapi_base")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &CreateOrderResponse{
|
|
|
|
|
Amount: amount,
|
|
|
|
|
PayAmount: payAmount,
|
|
|
|
|
FeeRate: feeRate,
|
|
|
|
|
ResultType: payment.CreatePaymentResultOAuthRequired,
|
|
|
|
|
PaymentType: req.PaymentType,
|
|
|
|
|
OAuth: &payment.WechatOAuthInfo{
|
|
|
|
|
AuthorizeURL: authorizeURL,
|
|
|
|
|
AppID: appID,
|
|
|
|
|
Scope: "snsapi_base",
|
|
|
|
|
RedirectURL: "/auth/wechat/payment/callback",
|
|
|
|
|
},
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PaymentService) validateSelectedCreateOrderInstance(ctx context.Context, req CreateOrderRequest, sel *payment.InstanceSelection) error {
|
|
|
|
|
if !requiresWeChatJSAPICompatibleSelection(req, sel) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
expectedAppID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
selectedAppID := provider.ResolveWxpayJSAPIAppID(sel.Config)
|
|
|
|
|
if selectedAppID == "" || selectedAppID != expectedAppID {
|
|
|
|
|
return infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "selected payment instance is not compatible with the current WeChat OAuth app")
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func requiresWeChatJSAPICompatibleSelection(req CreateOrderRequest, sel *payment.InstanceSelection) bool {
|
|
|
|
|
if sel == nil || sel.ProviderKey != payment.TypeWxpay || payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return req.IsWeChatBrowser || strings.TrimSpace(req.OpenID) != ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 17:35:12 +08:00
|
|
|
func (s *PaymentService) getWeChatPaymentOAuthCredential(ctx context.Context) (string, string, error) {
|
|
|
|
|
if s == nil || s.configService == nil || s.configService.settingRepo == nil {
|
|
|
|
|
return "", "", infraerrors.ServiceUnavailable(
|
|
|
|
|
"WECHAT_PAYMENT_MP_NOT_CONFIGURED",
|
|
|
|
|
"wechat in-app payment requires a complete WeChat MP OAuth credential",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
cfg, err := (&SettingService{settingRepo: s.configService.settingRepo}).GetWeChatConnectOAuthConfig(ctx)
|
2026-04-21 07:48:42 -07:00
|
|
|
appID := strings.TrimSpace(cfg.AppIDForMode("mp"))
|
|
|
|
|
appSecret := strings.TrimSpace(cfg.AppSecretForMode("mp"))
|
|
|
|
|
if err != nil || !cfg.SupportsMode("mp") || appID == "" || appSecret == "" {
|
2026-04-20 23:34:57 +08:00
|
|
|
return "", "", infraerrors.ServiceUnavailable(
|
|
|
|
|
"WECHAT_PAYMENT_MP_NOT_CONFIGURED",
|
|
|
|
|
"wechat in-app payment requires a complete WeChat MP OAuth credential",
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-04-21 07:48:42 -07:00
|
|
|
return appID, appSecret, nil
|
2026-04-20 23:34:57 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func classifyCreatePaymentError(req CreateOrderRequest, providerKey string, err error) error {
|
|
|
|
|
if err == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if providerKey == payment.TypeWxpay &&
|
|
|
|
|
payment.GetBasePaymentType(req.PaymentType) == payment.TypeWxpay &&
|
|
|
|
|
strings.Contains(err.Error(), "wxpay h5 payments are not authorized for this merchant") {
|
|
|
|
|
return infraerrors.ServiceUnavailable(
|
|
|
|
|
"WECHAT_H5_NOT_AUTHORIZED",
|
|
|
|
|
"wechat h5 payment is not available for this merchant",
|
|
|
|
|
).WithMetadata(map[string]string{
|
|
|
|
|
"action": "open_in_wechat_or_scan_qr",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func buildCreateOrderResponse(order *dbent.PaymentOrder, req CreateOrderRequest, payAmount float64, sel *payment.InstanceSelection, pr *payment.CreatePaymentResponse, resultType payment.CreatePaymentResultType) *CreateOrderResponse {
|
|
|
|
|
return &CreateOrderResponse{
|
|
|
|
|
OrderID: order.ID,
|
|
|
|
|
Amount: order.Amount,
|
|
|
|
|
PayAmount: payAmount,
|
|
|
|
|
FeeRate: order.FeeRate,
|
|
|
|
|
Status: OrderStatusPending,
|
|
|
|
|
ResultType: resultType,
|
|
|
|
|
PaymentType: req.PaymentType,
|
|
|
|
|
OutTradeNo: order.OutTradeNo,
|
|
|
|
|
PayURL: pr.PayURL,
|
|
|
|
|
QRCode: pr.QRCode,
|
|
|
|
|
ClientSecret: pr.ClientSecret,
|
|
|
|
|
OAuth: pr.OAuth,
|
|
|
|
|
JSAPI: pr.JSAPI,
|
|
|
|
|
JSAPIPayload: pr.JSAPI,
|
|
|
|
|
ExpiresAt: order.ExpiresAt,
|
|
|
|
|
PaymentMode: sel.PaymentMode,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func buildWeChatPaymentOAuthStartURL(req CreateOrderRequest, scope string) (string, error) {
|
|
|
|
|
u, err := url.Parse("/api/v1/auth/oauth/wechat/payment/start")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("build wechat payment oauth start url: %w", err)
|
|
|
|
|
}
|
|
|
|
|
q := u.Query()
|
|
|
|
|
q.Set("payment_type", strings.TrimSpace(req.PaymentType))
|
|
|
|
|
if req.Amount > 0 {
|
|
|
|
|
q.Set("amount", strconv.FormatFloat(req.Amount, 'f', -1, 64))
|
|
|
|
|
}
|
|
|
|
|
if orderType := strings.TrimSpace(req.OrderType); orderType != "" {
|
|
|
|
|
q.Set("order_type", orderType)
|
|
|
|
|
}
|
|
|
|
|
if req.PlanID > 0 {
|
|
|
|
|
q.Set("plan_id", strconv.FormatInt(req.PlanID, 10))
|
|
|
|
|
}
|
|
|
|
|
if scope = strings.TrimSpace(scope); scope != "" {
|
|
|
|
|
q.Set("scope", scope)
|
|
|
|
|
}
|
|
|
|
|
if redirectTo := paymentRedirectPathFromURL(req.SrcURL); redirectTo != "" {
|
|
|
|
|
q.Set("redirect", redirectTo)
|
|
|
|
|
}
|
|
|
|
|
u.RawQuery = q.Encode()
|
|
|
|
|
return u.String(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func paymentRedirectPathFromURL(rawURL string) string {
|
|
|
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
|
|
|
if rawURL == "" {
|
|
|
|
|
return "/purchase"
|
|
|
|
|
}
|
|
|
|
|
if strings.HasPrefix(rawURL, "/") && !strings.HasPrefix(rawURL, "//") {
|
|
|
|
|
return normalizePaymentRedirectPath(rawURL)
|
|
|
|
|
}
|
|
|
|
|
u, err := url.Parse(rawURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "/purchase"
|
|
|
|
|
}
|
|
|
|
|
path := strings.TrimSpace(u.EscapedPath())
|
|
|
|
|
if path == "" {
|
|
|
|
|
path = strings.TrimSpace(u.Path)
|
|
|
|
|
}
|
|
|
|
|
if path == "" || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
|
|
|
|
|
return "/purchase"
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(u.RawQuery) != "" {
|
|
|
|
|
path += "?" + u.RawQuery
|
|
|
|
|
}
|
|
|
|
|
return normalizePaymentRedirectPath(path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func normalizePaymentRedirectPath(path string) string {
|
|
|
|
|
path = strings.TrimSpace(path)
|
|
|
|
|
if path == "" {
|
|
|
|
|
return "/purchase"
|
|
|
|
|
}
|
|
|
|
|
if path == "/payment" {
|
|
|
|
|
return "/purchase"
|
|
|
|
|
}
|
|
|
|
|
if strings.HasPrefix(path, "/payment?") {
|
|
|
|
|
return "/purchase" + strings.TrimPrefix(path, "/payment")
|
|
|
|
|
}
|
|
|
|
|
return path
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 21:08:51 +08:00
|
|
|
// --- Order Queries ---
|
|
|
|
|
|
|
|
|
|
func (s *PaymentService) GetOrder(ctx context.Context, orderID, userID int64) (*dbent.PaymentOrder, error) {
|
|
|
|
|
o, err := s.entClient.PaymentOrder.Get(ctx, orderID)
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
return o, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PaymentService) GetOrderByID(ctx context.Context, orderID int64) (*dbent.PaymentOrder, error) {
|
|
|
|
|
o, err := s.entClient.PaymentOrder.Get(ctx, orderID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
|
|
|
|
|
}
|
|
|
|
|
return o, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PaymentService) GetUserOrders(ctx context.Context, userID int64, p OrderListParams) ([]*dbent.PaymentOrder, int, error) {
|
|
|
|
|
q := s.entClient.PaymentOrder.Query().Where(paymentorder.UserIDEQ(userID))
|
|
|
|
|
if p.Status != "" {
|
|
|
|
|
q = q.Where(paymentorder.StatusEQ(p.Status))
|
|
|
|
|
}
|
|
|
|
|
if p.OrderType != "" {
|
|
|
|
|
q = q.Where(paymentorder.OrderTypeEQ(p.OrderType))
|
|
|
|
|
}
|
|
|
|
|
if p.PaymentType != "" {
|
|
|
|
|
q = q.Where(paymentorder.PaymentTypeEQ(p.PaymentType))
|
|
|
|
|
}
|
|
|
|
|
total, err := q.Clone().Count(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, 0, fmt.Errorf("count user orders: %w", err)
|
|
|
|
|
}
|
|
|
|
|
ps, pg := applyPagination(p.PageSize, p.Page)
|
|
|
|
|
orders, err := q.Order(dbent.Desc(paymentorder.FieldCreatedAt)).Limit(ps).Offset((pg - 1) * ps).All(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, 0, fmt.Errorf("query user orders: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return orders, total, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AdminListOrders returns a paginated list of orders. If userID > 0, filters by user.
|
|
|
|
|
func (s *PaymentService) AdminListOrders(ctx context.Context, userID int64, p OrderListParams) ([]*dbent.PaymentOrder, int, error) {
|
|
|
|
|
q := s.entClient.PaymentOrder.Query()
|
|
|
|
|
if userID > 0 {
|
|
|
|
|
q = q.Where(paymentorder.UserIDEQ(userID))
|
|
|
|
|
}
|
|
|
|
|
if p.Status != "" {
|
|
|
|
|
q = q.Where(paymentorder.StatusEQ(p.Status))
|
|
|
|
|
}
|
|
|
|
|
if p.OrderType != "" {
|
|
|
|
|
q = q.Where(paymentorder.OrderTypeEQ(p.OrderType))
|
|
|
|
|
}
|
|
|
|
|
if p.PaymentType != "" {
|
|
|
|
|
q = q.Where(paymentorder.PaymentTypeEQ(p.PaymentType))
|
|
|
|
|
}
|
|
|
|
|
if p.Keyword != "" {
|
|
|
|
|
q = q.Where(paymentorder.Or(
|
|
|
|
|
paymentorder.OutTradeNoContainsFold(p.Keyword),
|
|
|
|
|
paymentorder.UserEmailContainsFold(p.Keyword),
|
|
|
|
|
paymentorder.UserNameContainsFold(p.Keyword),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
total, err := q.Clone().Count(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, 0, fmt.Errorf("count admin orders: %w", err)
|
|
|
|
|
}
|
|
|
|
|
ps, pg := applyPagination(p.PageSize, p.Page)
|
|
|
|
|
orders, err := q.Order(dbent.Desc(paymentorder.FieldCreatedAt)).Limit(ps).Offset((pg - 1) * ps).All(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, 0, fmt.Errorf("query admin orders: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return orders, total, nil
|
|
|
|
|
}
|