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).
Accounts use soft-delete (setting deleted_at), so PostgreSQL's
ON DELETE CASCADE on scheduled_test_plans.account_id never fires.
Add plan deletion to the existing account deletion transaction
to ensure atomicity.
ClosesWei-Shaw/sub2api#1728
Add quota exceeded check to IsSchedulable() and refactor
shouldClearStickySession to delegate to IsSchedulable(), eliminating
duplicated logic and fixing missed overload/rate-limit/expired checks.
Frontend displays quota exceeded status independently via quota fields.
Admin GET /api/v1/admin/payment/providers previously returned every
config value — including privateKey / apiV3Key / secretKey etc. —
verbatim. Any future XSS on the admin UI would hand attackers the
full set of production payment credentials, and the plaintext values
sat unnecessarily in browser memory for every operator.
Treat those fields as write-only from the admin surface:
- decryptAndMaskConfig() strips sensitive keys from the GET response.
The authoritative list is an explicit per-provider registry that
mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag:
alipay → privateKey, publicKey, alipayPublicKey
wxpay → privateKey, apiV3Key, publicKey
stripe → secretKey, webhookSecret (publishableKey stays plain)
easypay → pkey
Payment runtime still reads the full config via decryptConfig, so
nothing at the gateway changes.
- mergeConfig() treats an empty value for a sensitive key as "leave
unchanged" — the admin UI omits unchanged secrets so operators can
tweak non-sensitive settings without re-entering credentials.
- Admin dialog (PaymentProviderDialog.vue):
* secret inputs get autocomplete="new-password", data-1p-ignore,
data-lpignore and data-bwignore so password managers do not
offer to save provider credentials
* in edit mode the required-field check skips sensitive fields
(empty is the "keep existing" signal) and the placeholder shows
"leave empty to keep" instead of the default example value
* create mode still requires every non-optional field, including
secrets, since there is nothing to preserve
- Unit test renamed to TestIsSensitiveProviderConfigField, covers
the per-provider registry and specifically asserts that Stripe's
publishableKey is NOT treated as a secret.
The native Alipay provider previously tried to embed the payment page
URL into a QR code on the client — the URL is not a scannable payload
so the QR never worked. Merchants also hit a H5 detection mismatch
whenever the backend UA sniffer missed iPadOS 13+ or embedded browsers,
and the popup window was too small for Alipay's standard checkout
layout (QR + account-login panel on the right), forcing the user to
scroll horizontally and vertically.
Changes:
Backend
- alipay.go: drop QR-on-URL path. Use redirect-only flow —
alipay.trade.page.pay for PC (returns a gateway URL the browser
opens in a new window) and alipay.trade.wap.pay for H5 (returns a
URL the browser jumps to). Both flows produce pages on
openapi.alipaydev.com / excashier.alipay.com; the client never
renders a QR itself.
- payment_handler.go: add optional is_mobile bool to
CreateOrderRequest so the frontend can declare the device
explicitly. Server still falls back to UA sniffing when absent.
Frontend
- types/payment.ts, PaymentView.vue: declare is_mobile in
CreateOrderRequest and pass the computed isMobileDevice() value.
- providerConfig.ts: replace the two fixed POPUP_WINDOW_FEATURES
constants with getPaymentPopupFeatures(), which prefers 1250×900
(Alipay's checkout footprint), clamps to window.screen.avail* and
centers the popup so it never overflows on smaller laptops.
- PaymentQRDialog.vue, PaymentStatusPanel.vue, StripePaymentInline.vue,
PaymentView.vue: use the new helper at all popup call sites.
Subscription-mode billing was consuming quota at TotalCost (raw) instead of
ActualCost (TotalCost * RateMultiplier), so per-group rate multipliers —
including free subscriptions (multiplier = 0) — were silently ignored.
Switch the three subscription cost writes in buildUsageBillingCommand,
finalizePostUsageBilling, and the legacy postUsageBilling fallback to
ActualCost, and add a table-driven test covering 2x / 0.5x / free multipliers
plus a balance-mode regression check.
Without TOTP_ENCRYPTION_KEY, saved payment configs were lost on restart because
the AES round-trip failed silently. Write new records as plaintext JSON; read
path tries JSON first, falls back to legacy AES decrypt when a key is present,
and treats unreadable values as empty so admins can re-enter them via the UI.
Each retry in the SetOutboxWatermark loop now gets its own 5s context.
Previously a shared context could already be expired when the second or
third attempt ran, making the retries pointless.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Fixes#1691
- pollOutbox() reused a 10s context for SetOutboxWatermark after event
processing could take much longer, causing "outbox watermark write
failed: context deadline exceeded". The watermark never advanced so
the same 200 events were reprocessed every poll cycle, spiking CPU.
Now uses an independent 5s context with up to 3 retries (200ms apart).
- When multiple Codex accounts sharing the same 21-22 groups are all
rate-limited in quick succession, each account_changed event triggered
redundant bucket rebuild attempts for the same groups. Introduce
batchSeenKey{groupID, platform} and thread a seen map through the
handler chain; rebuildBucketsForPlatform skips (group, platform) pairs
already rebuilt within the same poll batch (~80% fewer rebuild calls in
the 5-accounts-same-groups scenario).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Consolidates 9 call sites of resolveUpstreamResponseReadLimit + readUpstreamResponseBodyLimited + ErrUpstreamResponseBodyTooLarge error handling into a single ReadUpstreamResponseBody function with TooLargeWriter callback for API-format-specific error responses (Anthropic, OpenAI, countTokens).
Product name (e.g. "快代码科技工作室 100 元") should show the user's
original recharge amount (limitAmount), not the fee-inclusive pay amount.
The gateway receives payAmount separately for actual charging.
- Fix gofmt alignment in setting_handler.go, settings.go, payment_config_service.go
- Add payment_balance_recharge_multiplier and payment_recharge_fee_rate
to API contract test expected JSON
Backend:
- Use cfg.RechargeFeeRate in order creation instead of hardcoded 0
- Remove dead getFeeRate stub method
- All amounts computed server-side: order_amount, pay_amount, fee_rate
Frontend - PaymentView:
- Read recharge_fee_rate from checkout-info API (not per-method)
- Show fee breakdown only when fee_rate > 0
- Show credited amount only when multiplier ≠ 1
Frontend - Order display (user + admin):
- Fix fee_rate * 100 bug (fee_rate is already a percentage)
- OrderTable: show pay_amount as primary, fee/credited as sub-lines
- AdminOrderDetail: full breakdown (base/fee/paid/credited)
- AdminRefundDialog: label "到账金额" for clarity
- PaymentResultView: show pay_amount with fee info
Types + i18n:
- Add recharge_fee_rate to CheckoutInfoResponse
- Add fee_rate to CreateOrderResult
- Add translations: creditedAmount, fee, baseAmount, includedInPayAmount