Accounts use soft-delete (setting deleted_at), so PostgreSQL's
ON DELETE CASCADE on scheduled_test_plans.account_id never fires.
This leaves orphaned plans that continue executing and cannot be
managed from the frontend since the account no longer exists.
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.
Review fixes (P0/P1/P2):
- P0: move nil receiver guards before stage check in PoolClaudeRectifier
and PoolAntigravityRectifier to prevent panic on nil receiver
- P1: add explicit bedrock/upstream cases to BucketFor for self-documenting
intent (return empty bucket = no pool participation)
- P2: replace harvester emit de-dupe from O(N²) [][]byte scan to O(1) map
- P2: add lineBufCap (256KB) to SSE line accumulator to prevent unbounded
growth from malformed upstream streams without newlines
New test cases (29 → 39 total in signature package):
- MultipleAssistantMessages: verify replacement across user/assistant mix
- MalformedContentSkipped: partial JSON doesn't panic or corrupt counter
- SSE_SignatureSplitAcrossReads: line split across multiple Read calls
- SSE_LineBufCapTruncatesHugeLine: lineBuf cap prevents OOM
- NilRequestNoop: PoolAntigravityRectifier with nil Request
- NilReceiverNoPanic: PoolClaudeRectifier nil receiver
- StripAntigravityRectifier_BothStages: verify both stages call strip func
Covers the Lua script logic (ZADD + lazy TTL expiry + capacity trim)
via in-process miniredis — no Docker required, runs under `go test
-tags unit`.
10 cases: basic add/topN, fewer-than-N, empty bucket, capacity
trim to newest 3, lazy expiry on Add, TopN-does-not-filter-by-TTL
(design rule: stale sigs survive until next Add), duplicate score
update, empty-input noop, zero-N guard, Size accuracy.
Also keeps the integration test file (build tag `integration`) for
environments with Docker + testcontainers.
Covers:
- ReplaceThinkingSignaturesInBody / InClaudeRequest:
M>N cycling, empty pool, no thinking blocks, empty signature
preservation, string-content messages, nil pool guard
- Strip / Pool rectifier strategies:
Strip stage-1 unconditional, stage-2 gated on tool error;
Pool empty→proceed=false (rule A), no replacements→abort,
one-shot semantics (stage-2 always declines), pool error
treated as empty
- BucketFor: oauth/setup-token share, apikey per-id, unknown empty
- Harvester SSE: content_block_start extraction, per-response
dedupe, Skip callback, bucket/capacity guards
- Harvester non-streaming: buffers until Close then parses once
Along the way caught and fixed a path-construction bug in
ReplaceThinkingSignaturesInBody: it used gjson.ForEach's key.Raw
which is empty for array indices, producing malformed sjson paths
like "messages..content..signature". Switched to manual index
counters so paths are always well-formed integers.
Adds a Signature Pool Size numeric input to the Rectifier settings
section with a hint explaining the 0-vs-positive semantics. When
pool size > 0, the Thinking Signature and API Key Signature toggle
hints dynamically switch from "strip signatures and retry" to
"replace with pooled signatures (pass through when pool is empty)",
matching the runtime strategy swap.
Includes both zh and en locale keys (poolSize, poolSizeHint,
poolSizeUnit, thinkingSignatureHintPool, apikeySignatureHintPool)
plus the signature_pool_size field in the TypeScript RectifierSettings
interface and save payload.
Introduces the pool-replace retry strategy end-to-end. When
RectifierSettings has SignaturePoolSize>0 and the account's per-type
sub-switch is on, the factory returns a PoolClaudeRectifier /
PoolAntigravityRectifier that fetches the freshest signatures from the
Redis pool and cycles them through the request's thinking blocks
(M>N cycling). Rule A: an empty pool transparently passes the
original upstream error back — no fallback to strip.
Key pieces:
- PoolClaudeRectifier / PoolAntigravityRectifier in internal/service/signature
- signatureRectifierFactory in the service package selects strategy per
request via shouldUsePool(ctx, account) which implements the agreed
decision table (Enabled + SignaturePoolSize>0 + per-type sub-switch)
- WrapResponseBody helper on the factory is invoked at each Claude-native
DoWithTLS entry point (main Forward, Anthropic passthrough, Bedrock) to
run the Harvester over the upstream body; no-op when pool disabled
- ctxkey.IsSignatureRectifyRetry is set on all retry contexts (Claude
two-stage, count_tokens, Antigravity signature retries) so the harvester
skips ingesting signatures from retry responses and does not pollute the
pool with values we ourselves injected
- NewGatewayService / NewAntigravityGatewayService now accept
signature.SignaturePool; wire_gen.go updated accordingly
Antigravity responses are raw Gemini format so WrapResponseBody is not
called there — harvesting stays Claude-only as designed. Antigravity can
still *read* from the shared OAuth pool on retry; whether cross-ecosystem
signatures verify upstream is the question the future PoC will answer.
Introduces the building blocks for the thinking-signature pool feature
without activating any runtime behavior. Nothing uses these new types
yet — Phase 3 will wire them into the retry loops.
New package internal/service/signature adds:
- SignaturePool interface + Bucket helpers (oauth shared / apikey per-account)
- ReplaceThinkingSignaturesInBody / ReplaceThinkingSignaturesInClaudeRequest
pure functions that cycle through pool entries for M>N replacements
- Harvester io.ReadCloser decorator for SSE + non-streaming JSON that
extracts content_block.signature fields best-effort into the pool
- 1h soft TTL constant for lazy expiry
New repository adapter internal/repository/signature_pool_cache.go
implements Redis ZSET storage with a single Lua script handling atomic
add + lazy expiry cleanup + capacity trim. Registered via
ProvideSignaturePool in the wire ProviderSet.
Settings extension: RectifierSettings gains a SignaturePoolSize int
field (0 = pool disabled / sticks with strip behavior; >0 = pool replace
is active). Threaded through service view, DTO, and handler GET/PUT
paths with bounds validation (max 1000).
ctxkey.IsSignatureRectifyRetry added so the harvester can later skip
ingesting signatures from retry requests we ourselves injected.
Introduce internal/service/signature package with ClaudeRectifier and
AntigravityRectifier interfaces plus StripClaudeRectifier /
StripAntigravityRectifier implementations that wrap the legacy
FilterThinkingBlocksForRetry / FilterSignatureSensitiveBlocksForRetry
and stripThinkingFromClaudeRequest / stripSignatureSensitiveBlocksFromClaudeRequest
functions. Refactor the Claude and Antigravity retry loops
(including count_tokens) to delegate body transformation to the
rectifier, keeping all surrounding scaffolding (ops events, logging,
time budget checks, HTTP plumbing) unchanged.
Also extracts the looksLikeToolSignatureError predicate previously
inlined as an anonymous closure.
Zero behavior change: all existing unit tests pass unchanged. This
prepares for Phase 2 where a Pool strategy will be plugged in via
the same interface.
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.
Alipay's page is ~1200x900; a fixed 1250x780 still clipped vertically.
Replace the constant with getPaymentPopupFeatures(): it prefers
1250x900, clamps to window.screen.avail*, and centers the popup so
it never overflows the visible work area on smaller laptops.
Drop POPUP_WINDOW_FEATURES and STRIPE_POPUP_WINDOW_FEATURES in favor of
the helper — all five call sites migrated.
Bump version to 0.1.114.9
Alipay's standard checkout (QR + account login panel) needs ~1200px
width. The default popup was 1000x750, forcing a horizontal scrollbar.
Unify to the wider size that the Stripe-alipay popup already used.
Bump version to 0.1.114.8
Previous pattern-based isSensitiveConfigField treated any key containing
"key" as a secret and stripped it from the admin response, which wrongly
hid Stripe's publishableKey (a public value). Edit dialogs then failed
its required-field validation because the backend no longer returned it.
Replace the pattern matcher with an explicit per-provider registry that
mirrors the frontend PROVIDER_CONFIG_FIELDS, so only true secrets are
masked/preserved:
- alipay: privateKey, publicKey, alipayPublicKey
- wxpay: privateKey, apiV3Key, publicKey
- stripe: secretKey, webhookSecret (publishableKey stays plaintext)
- easypay: pkey
Bump version to 0.1.114.7
- Strip sensitive provider config keys (privateKey/publicKey/apiV3Key/
secretKey/webhookSecret/pkey/password/etc.) from admin list/detail API
- mergeConfig: empty value for a sensitive key preserves the stored secret
- Admin dialog: sensitive inputs add autocomplete="new-password" +
data-*ignore + spellcheck="false" to block browser password managers;
edit-mode placeholder shows "leave empty to keep"; skip required
validation for sensitive fields when editing
Bump version to 0.1.114.6
Backend's UA heuristic (mobile|android|iphone|ipad|ipod) misidentifies
iPadOS 13+ (reports as Mac) and certain embedded browsers that strip
the "Mobile" keyword, so H5 users got a PC alipay.trade.page.pay URL.
Frontend then tried to window.location.href into it — navigation went
somewhere unexpected or the popup fallback left the user staring at
the paying-state frontend.
Let the frontend — which has navigator.userAgentData.mobile and better
context — declare is_mobile on the create-order request. Backend uses
the explicit value when present, falling back to UA detection only
when the client didn't send one (preserves old clients / direct API
callers).
The previous change used TradePreCreate (FACE_TO_FACE_PAYMENT), which
requires the merchant to sign "当面付". Our merchant only has "电脑网站
支付" so Alipay returns ACQ.ACCESS_FORBIDDEN.
Go back to TradePagePay but fix the original bug differently: fill
only PayURL (do NOT fill QRCode). Frontend:
- PC hits the pay_url branch → openWindow() popup (redirect to
Alipay's own page, which shows login/QR natively)
- H5 hits the mobile+pay_url branch → window.location.href jump
This matches Alipay's "电脑网站支付" UX and works with the current
merchant signing scope. No client-side QR encoding of the gateway URL
(which was never a valid scannable payload).
PC branch was calling TradePagePay (alipay.trade.page.pay), which
returns a gateway redirect URL — not a scannable QR payload. That URL
was being written to both PayURL and QRCode, so the frontend's qr_code
branch encoded the URL string into an image. Users scanning it got a
generic HTTP link instead of an Alipay order.
Switch PC to TradePreCreate (FACE_TO_FACE_PAYMENT): returns a native
qr_code string (qr.alipay.com/...) that renders as a real order QR.
Only fill QRCode, leave PayURL empty so the frontend takes the QR
popup branch. Surface empty-qr_code responses with sub_code/sub_msg
for debugging. H5 path (TradeWapPay) unchanged.
- safe/safe.go: pass context.Background() to slog.LogAttrs (SA1012)
- capture_fingerprint/main.go: wrap ln/raw Close() defers to swallow err
- capture_fingerprint/peek_conn.go, serve_h2.go: ignore bytes.Buffer.Write
return (cannot fail)
- verify_fingerprint/main.go: wrap resp.Body.Close() defer
These were pre-existing issues on release/custom-0.1.114 also failing
on v0.1.114.1. Fixing now so the current CI run turns green.
## Bug fixes
- EditAccountModal: auto-generated profiles (__auto__:acc-*) are no longer
hidden from the dropdown; accounts bound to their own auto profile can
now see and keep the selection.
- AccountResponse DTO: emit tls_fingerprint_randomized so the "randomized"
badge and reshuffle affordance render on subsequent edits.
## Feature
- TLS fingerprint profile list returns bound_account_count per profile,
aggregated via a single grouped SQL query over accounts.extra.
- Dropdowns and admin management table surface the binding count so admins
can judge whether a fingerprint is shared before editing or deleting it.
## Performance
- Migration 108 adds a partial + expression index on
(extra->>'tls_fingerprint_profile_id') WHERE extra ? '...',
enabling Index Only Scan + HashAggregate for the new query.
- Extraction uses (extra->>'...')::bigint so PostgreSQL performs the cast;
Go-side parsing and NullString plumbing are removed.
## Backend
- AccountRepository: add CountByTLSFingerprintProfile; implement in ent
repo via grouped raw SQL.
- TLSFingerprintProfileService: add ProfileWithBinding + ListWithBindingCount.
- Admin handler List now returns the enriched payload.
- AccountResponse DTO: add tls_fingerprint_randomized (optional, omitempty).
- Update all five AccountRepository test stubs for the new interface method
(account_service_delete_test, gateway_multiplatform_test,
gemini_multiplatform_test, ratelimit_session_window_test,
server/api_contract_test).
## Frontend
- TLSFingerprintProfile type: add optional bound_account_count.
- EditAccountModal + CreateAccountModal: show " (N)" suffix on options
with active bindings; drop the auto-profile filter.
- TLSFingerprintProfilesModal admin table: new "使用中 / In use" column
with amber-highlighted count.
- i18n zh/en: add columns.boundAccounts.
## Compatibility
- New fields are all optional (omitempty) — existing OAuth accounts and
older frontends behave as before.
- No data migration required; empty extra entries are ignored by index
and aggregation alike.
Restructure the EasyPay recommendation block to present two options side
by side so users can pick by funding channel and settlement currency:
- Domestic / CNY — ZPay: official Alipay/WeChat API with 1.6% fee and
T+1 automatic settlement (existing recommendation, expanded with fee
and settlement details).
- International / USDT or USD — Kyren Topup (https://kyren.top): global
payment stack supporting WeChat Pay and Alipay with local-currency
checkout, USD settlement, and USDT/USD withdrawal. Fees: WeChat 2%,
Alipay 2.5%, withdrawal 0.1% ($40 min / $150 max). Fills the gap for
users who cannot use domestic Chinese channels or tolerate Stripe's
6%+ fees.
Both recommendations share a single disclaimer at the end. The Chinese
heading uses "易支付" while the English one keeps "EasyPay".
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.
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.