Skip to content

fix: subscription billing, alipay redirect + H5, payment secrets, 128MB response limit#1731

Open
touwaeriol wants to merge 8 commits intoWei-Shaw:mainfrom
touwaeriol:fix/rate-billing-autofill-response-limit
Open

fix: subscription billing, alipay redirect + H5, payment secrets, 128MB response limit#1731
touwaeriol wants to merge 8 commits intoWei-Shaw:mainfrom
touwaeriol:fix/rate-billing-autofill-response-limit

Conversation

@touwaeriol
Copy link
Copy Markdown
Contributor

@touwaeriol touwaeriol commented Apr 17, 2026

Closes #1711
Closes #1718
Closes #1719

背景 / Background

生产环境发现数个独立但都影响日常使用的 bug:订阅倍率被静默按 1x 扣费;Chrome 密码管理器误填账号 API Key 和支付 provider 凭证;支付配置在 TOTP_ENCRYPTION_KEY 未配时丢失;4K 图片生成响应超 8MB 被直接拒绝;Alipay 的 client-side QR 一直是坏的(URL 不是可扫描载荷);admin 读取支付配置接口把所有凭证明文返回;popup 窗口太小装不下 Alipay 收银台。

Several independent production issues: subscription billing silently charging 1x; Chrome autofilling the wrong API keys and payment provider credentials; payment configs lost after save when TOTP_ENCRYPTION_KEY was unset; 4K image generation responses rejected as too large; Alipay's client-side QR never worked (the URL is not a scannable payload); admin config API returned payment credentials verbatim; popup too small for Alipay's checkout layout.


目的 / Purpose

把上述问题一次性修到可发布状态:订阅倍率忠实落账;密码管理器在所有敏感输入框禁用;支付配置以明文 JSON 存储并保留旧密文回退;Alipay 改为原生跳转流(PC page-pay、H5 wap-pay),popup 尺寸按收银台实际占位确定;admin 响应脱敏所有敏感字段,编辑态将空值视为"保持不变";上游响应读取上限放宽至 128MB。

Land all of the above in one shippable PR: subscription multiplier faithfully billed; password managers disabled on every sensitive input; payment configs stored as plaintext JSON with legacy ciphertext fallback; Alipay switched to native redirect flows (PC page-pay, H5 wap-pay) with popup sized to the real checkout footprint; admin responses redact sensitive fields and treat empty values as "leave unchanged" on edit; upstream response read limit raised to 128MB.


改动 / Changes

后端 / Backend

  • fix(usage) gateway_service.go:三处订阅扣费写入改用 ActualCost,订阅额度按倍率消耗(修 bug: 订阅分组统计费率不对,还是按照原消耗计费【计费问题 v0.1.113 版本】 #1711 [BUG] 订阅套餐定价为 0 的话,会影响订单和订阅套餐的显示 #1718 关于订阅bug 当前订阅分组倍率 不影响额度。请尽快修复 #1719
  • fix(billing) billing_service.go:三处 <=0 → 1.0 改为 <0 → 0admin_service.go 四处加 > 0 校验;删除死代码 IsFreeSubscription
  • feat(gateway):响应读取默认 8MB → 128MB(仍可配置);提取 config.DefaultUpstreamResponseReadMaxBytes 共享常量
  • fix(payment):新写入走明文 JSON;读取先 JSON 再回退 AES 密文;密钥缺失/错误视为空配置,管理员重存即完成迁移。legacy 分支标 Deprecated: + TODO(deprecated-legacy-ciphertext),几个版本后统一清理
  • fix(payment) alipay.go:去掉 QR-on-URL 路径,PC 用 alipay.trade.page.pay,H5 用 alipay.trade.wap.pay,返回网关 URL 由浏览器打开/跳转
  • fix(payment) payment_handler.goCreateOrderRequest 新增可选 is_mobile,允许前端显式声明设备;缺省回退 UA 嗅探
  • feat(payment) payment_config_providers.go:按 provider 列举的敏感字段注册表(alipay: privateKey/publicKey/alipayPublicKey;wxpay: privateKey/apiV3Key/publicKey;stripe: secretKey/webhookSecret;easypay: pkey)。decryptAndMaskConfig 脱敏 admin GET 响应;mergeConfig 将敏感字段的空值视为"保持不变";支付运行时仍走 decryptConfig,gateway 行为不变

  • fix(usage): three subscription billing writes in gateway_service.go now use ActualCost instead of TotalCost (fixes bug: 订阅分组统计费率不对,还是按照原消耗计费【计费问题 v0.1.113 版本】 #1711 [BUG] 订阅套餐定价为 0 的话,会影响订单和订阅套餐的显示 #1718 关于订阅bug 当前订阅分组倍率 不影响额度。请尽快修复 #1719)
  • fix(billing) billing_service.go: three <=0 → 1.0 clamps become <0 → 0; admin_service.go rejects rate_multiplier <= 0 on group/user-rate save; dead IsFreeSubscription removed
  • feat(gateway): default upstream non-streaming response read limit 8MB → 128MB (overridable); extract config.DefaultUpstreamResponseReadMaxBytes shared constant
  • fix(payment): new writes plaintext JSON; reads try JSON first, fall back to legacy AES ciphertext; unreadable values treated as empty for admin re-entry. Legacy branches annotated Deprecated: + TODO(deprecated-legacy-ciphertext) for future cleanup
  • fix(payment) alipay.go: drop the QR-on-URL path. Use alipay.trade.page.pay for PC and alipay.trade.wap.pay for H5 — both return a gateway URL the browser opens in a new window or navigates to
  • fix(payment) payment_handler.go: add optional is_mobile to CreateOrderRequest so the frontend can declare the device explicitly; server falls back to UA sniffing when absent
  • feat(payment) payment_config_providers.go: per-provider registry of sensitive fields (alipay: privateKey/publicKey/alipayPublicKey; wxpay: privateKey/apiV3Key/publicKey; stripe: secretKey/webhookSecret; easypay: pkey). decryptAndMaskConfig strips them from the admin GET response; mergeConfig treats an empty value on these keys as "leave unchanged". Payment runtime still reads the full config via decryptConfig — gateway behavior unchanged

前端 / Frontend

  • fix(admin) EditAccountModal.vue:API Key input 补 autocomplete=new-password + data-1p-ignore / data-lpignore / data-bwignore
  • fix(billing):分组/用户倍率输入 min 从 0 收紧到 0.001,匹配后端校验
  • fix(payment) types/payment.ts / views/user/PaymentView.vueCreateOrderRequest 新增 is_mobile,下单时传 isMobileDevice() 计算值
  • fix(payment) providerConfig.ts:用 getPaymentPopupFeatures() 替换两个固定 POPUP_WINDOW_FEATURES 常量,优先 1250×900(Alipay 收银台尺寸),按 window.screen.avail* 夹紧并居中
  • fix(payment) PaymentQRDialog.vue / PaymentStatusPanel.vue / StripePaymentInline.vue / PaymentView.vue:所有 popup 调用点统一走新 helper
  • feat(payment) PaymentProviderDialog.vue:secret 输入框加 autocomplete="new-password" / data-1p-ignore / data-lpignore / data-bwignore,避免密码管理器保存;编辑态必填校验跳过敏感字段(空=保留原值),占位符显示"留空保持不变";新建态仍要求所有非可选字段

  • fix(admin) EditAccountModal.vue: add autocomplete=new-password + three data-*-ignore attrs on API Key input
  • fix(billing): rate multiplier input min tightened from 0 to 0.001 to match backend validation
  • fix(payment) types/payment.ts / views/user/PaymentView.vue: add is_mobile to CreateOrderRequest; pass the computed isMobileDevice() value when creating orders
  • fix(payment) providerConfig.ts: replace the two fixed POPUP_WINDOW_FEATURES constants with getPaymentPopupFeatures() — prefers 1250×900 (Alipay checkout footprint), clamps to window.screen.avail*, centers the popup
  • fix(payment) PaymentQRDialog.vue / PaymentStatusPanel.vue / StripePaymentInline.vue / PaymentView.vue: use the new helper at all popup call sites
  • feat(payment) PaymentProviderDialog.vue: secret inputs get autocomplete="new-password" / data-1p-ignore / data-lpignore / data-bwignore so password managers do not offer to save provider credentials. In edit mode the required-field check skips sensitive fields (empty = keep existing) and the placeholder shows "leave empty to keep"; create mode still requires every non-optional field

测试 / Tests

新增 billing_service_rate_multiplier_test.go(负数/0/正数)和 gateway_service_subscription_billing_test.go(2x/0.5x/免费订阅/余额回归);payment_config_providers_test.go 改名 TestIsSensitiveProviderConfigField,覆盖 provider 敏感字段注册表并断言 Stripe publishableKey 不被脱敏。go test -tags=unit ./... 全绿;pnpm typecheck 通过。

Added billing_service_rate_multiplier_test.go and gateway_service_subscription_billing_test.go; payment_config_providers_test.go renamed to TestIsSensitiveProviderConfigField covering the provider sensitive-field registry (explicitly asserts Stripe publishableKey is NOT redacted). Backend unit tests and frontend typecheck both pass.

…hertext fallback

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.
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.
… API key

Chrome's password manager matched the apikey-type account's Base URL + API Key
inputs as a login form and autofilled the last saved password by domain, so
editing a Gemini account could overwrite its apikey with a Claude key that
shared the same Base URL. Add autocomplete="new-password" plus data-*-ignore
attributes for 1Password / LastPass / Bitwarden to opt the field out of every
major password manager's autofill.
… 0 in compute

分组倍率和用户专属倍率在保存时没有校验,0 会触发计费层的 `<=0 → 1.0`
防御条款,结果订阅/余额分组按标准价扣费;完全是沉默地绕过了业务规则。

- 保存校验(admin_service):CreateGroup / UpdateGroup / BatchSetGroupRateMultipliers /
  UpdateUser.SyncUserGroupRates 全部要求 > 0
- 计算层(billing_service):三处 `<=0 → 1.0` 改为 `<0 → 0`;负数按 0 结算,
  避免配置异常被静默按 1x 收费
- 前端:分组倍率 / 用户专属倍率输入 min 统一到 0.001
- 删除未使用的 IsFreeSubscription 方法

测试:新增 billing_service_rate_multiplier_test.go 端到端验证;更新原有锁定
旧 `<=0 → 1.0` 行为的测试。
…gurable)

图片生成 API 返回的 base64 内联图响应经常超过 8MB 单次读取上限,被
ReadUpstreamResponseBody 拦截成 502 upstream_error。

单张 4K PNG base64 最坏约 67MB,多张候选图或 imageSize=4K 的 image_generation
一次请求能轻松到 30MB+。把默认上限提到 128MB 能覆盖 2-3 张 4K 图,相对
请求体上限 256MB 仍有缓冲;同时抽出 config.DefaultUpstreamResponseReadMaxBytes
共享常量,viper 默认值和 service 层兜底共用,消除两处同步魔法数字。

仍可通过 gateway.upstream_response_read_max_bytes 配置项覆盖。
明文 JSON 已经是新写入的默认格式;保留 AES 密文读取仅为兼容迁移期间的旧
记录,一旦所有部署通过管理后台重存过一次即可删除。标记为 deprecated 并加
TODO,几个版本后统一清理掉:payment.Encrypt / payment.Decrypt、两处
decryptConfig 的 AES 分支、PaymentConfigService.encryptionKey 和
DefaultLoadBalancer.encryptionKey 字段。
@touwaeriol touwaeriol force-pushed the fix/rate-billing-autofill-response-limit branch from 20baf26 to 61a008f Compare April 17, 2026 15:24
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.
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.
@touwaeriol touwaeriol changed the title fix: subscription rate multiplier, password autofill, payment config, 128MB response limit fix: subscription billing, alipay redirect + H5, payment secrets, 128MB response limit Apr 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant