Jinn deploy - #6540
Conversation
Add POST /api/user/login/email that signs in by email alone. First-time emails auto-create the user with random username/password + the configured QuotaForNewUser trial credits; existing emails log in. No verification step — gated by new EmailOnlyLoginEnabled admin flag (default true). Beta-only posture: anyone who knows an email can sign in as that user. Disable the flag before any external distribution. - common/constants.go: EmailOnlyLoginEnabled flag - model/option.go: wire flag into InitOptionMap + updateOptionMap - controller/user.go: LoginByEmail handler + emailLocalPart helper - router/api-router.go: route registration - i18n: new MsgUserEmailOnlyLoginDisabled (en/zh-CN/zh-TW) - CLAUDE.md: drop unused project-protection rule Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add POST /api/user/login/email/{request,verify}-code for Claude-desktop
parity: client posts email, server emails a 6-digit code, client posts
{email,code} to verify and receive a session. First-time emails
auto-create the user on verify-code. request-code returns success:true
regardless of email existence to prevent enumeration. Per-email resend
cooldown (default 30s) layered on the per-IP rate-limit middleware;
wrong-code attempts capped (default 3) before the code is invalidated.
V0 default flipped off: EmailOnlyLoginAutoCreateNoVerify=false. V0
remains controllable via the admin flag once all desktops ship V1.
- common/constants.go: V1 TTL/attempts/cooldown + V0-off default;
EmailOnlyLoginEnabled promoted to master switch (gates V0 + V1)
- common/verification.go: EmailLoginPurpose = "el"
- controller/user.go: RequestEmailLoginCode + VerifyEmailLoginCode
- model/option.go: wire new flags; fix latent suffix-check bug that
prevented EmailOnlyLoginAutoCreateNoVerify from ever loading from DB
- router/api-router.go: register the two new routes
- i18n: MsgEmailLoginCode{Sent,RateLimited,Invalid,TooManyAttempts}
- Dockerfile: GOPROXY=goproxy.cn for HK/CN builds
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The V1 sign-in code was the first 6 chars of a stripped UUID, i.e. hex chars (0-9, a-f). The client OTP input filters via /\D/g, so any letter chars in a generated code got silently dropped — users would type all 6 received chars and see only the digits land in the boxes. Switch to a numeric-only generator (crypto/rand) so the code matches the "6-digit code" promise in the UI and the digit-only input filter. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Document the new deploy path: git push → ssh deploy.sh → ~30s. Includes server layout, frontend-dist handover, rollback, and why the upload-based upgrade.sh path is kept only as a fallback. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… group
- VerifyEmailLoginCode now sets Group="free" on auto-create so JINN
email sign-ins land on the free-tier channels (1-6) immediately
instead of the unrouted "default" group.
- User.Edit now skips empty-string username/display_name/remark in the
partial-update map. Previous behavior wiped those fields on any PUT
that didn't include them (e.g. `{"id":N,"group":"plus"}` to promote
a user would clear their username and display_name).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wrap the email-login code template in a full HTML document and add more body text — bare <div> bodies trip SpamAssassin HTML_MIME_NO_HTML_TAG (+0.6) and short image-bearing bodies trip HTML_IMAGE_ONLY_08 (+1.8) once Mailtrap injects its tracking pixel. Also add the missing MIME-Version header in SendEmail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…or rest Browsers reject Access-Control-Allow-Origin: * when a request carries credentials, which silently breaks the JINN Excel taskpane's cookie-authed login (/api/user/login/*, /api/token/*, /api/user/self). Echo the specific Origin + Allow-Credentials only for an allowlist of trusted JINN taskpane origins; all other browser origins keep the prior open, non-credentialed relay access unchanged. Same-origin admin UI bypasses CORS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /api group set up gzip/rate-limit but never applied middleware.CORS(), so actual /api/* responses lacked Access-Control-Allow-Origin (only the OPTIONS preflight carried it via the global handler). Browsers require the header on the real response too, so the JINN taskpane's cookie-authed login (/api/user/login/*, /api/token/*, /api/user/self) was blocked despite a passing preflight. The /v1 relay group already does this; mirror it for /api. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The session cookie was SameSite=Strict, so the browser never sent it on
cross-origin requests. The JINN Excel taskpane (localhost:3000 / hosted →
apijinn) signs in with a session cookie, then mints its sk- key via cookie-
authed /api/token/* + /api/user/self — those came through unauthenticated under
Strict ("not logged in, no access token"). Switch to SameSite=None with
Secure=true (required for None; satisfied by prod HTTPS via Caddy).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-origin" This reverts commit 89eb328.
Office webviews (WKWebView/WebView2) refuse cross-registrable-domain third-party cookies, so the JINN Excel add-in's cross-origin session cookie can't authenticate the follow-up token-mint calls. Return the user's system access token in the login response so non-browser surfaces (desktop, Excel add-in) can authenticate via the Authorization header instead (UserAuth already accepts it). Add ensureAccessToken() to lazily generate+persist a token without clobbering an existing one. The browser dashboard ignores the field and keeps using the cookie. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- setting/payment_airwallex.go + option wiring (Enabled/ClientId/ApiKey/WebhookSecret/ApiBase) - service/airwallex: REST client with token cache; customers, consents, managed subscriptions, payment intents, HPP checkout URLs; unit tests - controller/subscription_payment_airwallex.go: /api/subscription/airwallex/pay (method-aware: card/applepay/googlepay live, alipay/wechat dormant behind active method types), /cancel (proration NONE, access to period end), /api/airwallex/webhook (HMAC-verified; consent.verified -> create subscription, subscription.active -> complete order / monthly renewal order, payment_intent.succeeded -> one-off completion) - SubscriptionPlan.AirwallexPriceId column; PaymentProviderAirwallex + method constants; model.GetLatestPendingSubscriptionOrder - RecurringCharger seam (agreement-based rails for CN edition) wired into the subscription lifecycle ticker with a daily gate; no-op for Airwallex Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upstream unconditionally overwrote Plan.Currency to USD after the empty-string default; JINN plan rows are CNY and the Airwallex processor passes plan.Currency to hosted checkout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ecurringOptions The recurring Hosted Payment Page lives at /#/standalone/recurring and only reads consent settings from a JSON recurringOptions query param; flat next_triggered_by/merchant_trigger_reason params are dropped by the page's param whitelist, so consents came back customer-triggered and the card form rejected input (verified against elements.bundle.min.js 1.160.0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ethod Live API (x-api-version 2025-02-14) rejects creates without them; the published schema.json predates both fields. billing_customer_id = the PA customer, collection_method = AUTO_CHARGE (consent-backed merchant renewals). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Portal (account.jinn.ccwu.cc:8444) supplies its own post-checkout landing page; origin validated against the JINN trusted-origins list (also added to credentialed CORS), untrusted/absent falls back to the console path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third undocumented required field on the live subscriptions/create API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t flow The unpinned live API routes subscriptions/create to the new Billing product, which demands bcus_ billing customers, psrc_ payment sources (which in turn reject scheduled consents), and collection_method. Pinning x-api-version on /api/v1/subscriptions/* validates the published-schema shape end-to-end; reverts the two speculative field additions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion for trade_no metadata Airwallex delivers subscription.active with a reduced object that omits metadata, so the handler never saw trade_no and dropped the order. When metadata is missing, fetch the subscription by id (pinned api version) and read trade_no from the full record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion) Real demo checkout/invoice objects wrapped in the webhook envelope. Findings: billing_checkout.completed carries metadata.trade_no directly; invoice.paid has NO metadata -> resolve trade_no via subscription_id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add AirwallexBillingCustomer table + GetAirwallexBillingCustomerId / SaveAirwallexBillingCustomerId (upsert). Required because Billing customers API has no merchant_customer_id filter; id must be stored to reuse across checkout, renewal, and cancellation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace legacy GenerateCustomerClientSecret/RecurringCheckoutURL tail of genAirwallexSubscriptionLink with a hosted Billing Checkout (SUBSCRIPTION mode). Persists/reuses the bcus_ customer id. Both Metadata and SubscriptionData.metadata carry trade_no + new_api_user_id so billing_checkout.completed and invoice.paid webhooks can both resolve the order. WeChat one-off branch unchanged; legacy helpers left intact for Task 10 cleanup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…newal handlers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The admin PUT /subscription/admin/plans/:id updateMap omitted airwallex_price_id, so plan rows could never be repointed to a Billing (2026-02-27) price via the API — blocking the Billing migration cutover. Add it alongside the other processor price ids. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The admin plan-edit form has no airwallex_price_id input, so a normal UI save submits it empty. Writing it unconditionally in updateMap (prev commit) would blank the Billing price on the next edit -> charged-but-not-upgraded. Only write it when non-empty; the cutover script always sends the pri_ id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion tests Reject the switch when the resolved current plan is already on an annual duration (nothing in the schema stops two enabled annual plans sharing an upgrade_group, so this was reachable despite the endpoint being monthly->annual only). Also adds test coverage for plan-not-found, plan-disabled, and empty-AirwallexPriceId target validation, which had no dedicated tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y-safe expire+repoint The invoice.paid handler silently fell back to the original plan whenever the subscription-items lookup or the per-item price->plan resolution errored. For an ordinary renewal that's the right call, but for a plan-switch proration invoice it granted another month of the OLD plan on an invoice that's already been charged at the annual rate and can never be reprocessed (keyed by invoice id). Both lookup failures now return an error so the webhook 500s and Airwallex retries delivery; the genuinely-unmapped-price case still falls back safely. Also reordered the plan-change branch so ExpireSupersededUserSubscriptions runs before the anchor order's PlanId repoint. These are two separate unlocked writes; running expire first means a failure between them leaves orig.PlanId unchanged, so a webhook retry recomputes planChanged=true and re-runs both steps (expire is idempotent) instead of permanently stranding a duplicate active subscription. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
WalkthroughThis PR adds email authentication, Airwallex payment and subscription billing, usage accounting and limits, recurring reconciliation, CORS updates, deployment procedures, and related persistence and test coverage. ChangesEmail authentication
Airwallex billing
Usage accounting and limits
Platform and deployment
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1⚔️ Resolve merge conflicts 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
CLAUDE.md (1)
109-122: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winRestore or explicitly replace the protected-project guardrail.
The change removes the
Protected Project Information — DO NOT Modify or Deleterule. Unless an equivalent policy exists elsewhere, future AI-assisted edits can modify or delete protected files and configuration without this repository-level safeguard.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@CLAUDE.md` around lines 109 - 122, Restore the “Protected Project Information — DO NOT Modify or Delete” rule in CLAUDE.md, or replace it with an equivalent repository-level safeguard that clearly identifies protected files and configuration and prohibits modifying or deleting them. Ensure the guardrail remains prominent and enforceable for AI-assisted edits.controller/user.go (1)
330-348: 🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy liftEvery login response now leaks the system management access token, including browser password logins.
setupLoginis shared byLogin,Verify2FALogin, passkey and OAuth flows, so the long-lived, non-expiringaccess_token— the credential that authorizes admin/user management endpoints — is now returned in the JSON body of every successful browser login and becomes reachable to any script on the page. The session cookie is HttpOnly precisely to avoid this; a single XSS now yields a persistent credential that survives logout. It also silently mints tokens for users who never requested one.Scope this to the clients that actually need it (e.g. only when the request carries the desktop/Office client marker, or a dedicated
/api/user/login/email/verify-coderesponse field) rather than the shared helper.🔒 Sketch
- accessToken := ensureAccessToken(user) + // Only non-browser surfaces that cannot carry the session cookie need this. + var accessToken string + if wantsAccessToken(c) { + accessToken = ensureAccessToken(user) + }with
wantsAccessTokengating on an explicit client header/flag set by the desktop and Office webview clients.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/user.go` around lines 330 - 348, Update the shared login response flow around setupLogin so accessToken is minted and included in data.access_token only when an explicit desktop/Office client marker or equivalent wantsAccessToken flag is present. Keep browser password, 2FA, passkey, and OAuth responses cookie-only, and ensure clients that do not request the token do not trigger ensureAccessToken(user).
🟠 Major comments (22)
service/subscription_recurring_test.go-17-22 (1)
17-22: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winUse
testify/requirefor fatal assertions.Replace the
t.Fatalf/t.Fatalassertions in this file withrequirechecks, and wrap the empty-registry no-panic expectation in the new test withrequire.NotPanics.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/subscription_recurring_test.go` around lines 17 - 22, Replace all t.Fatal/t.Fatalf assertions in service/subscription_recurring_test.go with equivalent testify/require checks, adding the required import. In TestChargeDueAgreementSubscriptionsNoopWhenEmpty, wrap ChargeDueAgreementSubscriptions with require.NotPanics to explicitly enforce the no-panic expectation.Source: Coding guidelines
controller/subscription_payment_airwallex.go-159-182 (1)
159-182: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winInsert the pending order before creating the Airwallex checkout.
If
order.Insert()fails aftergenAirwallexSubscriptionLinksucceeded, the hosted checkout already exists and the user can pay — but no local order row exists, sobilling_checkout.completedwill hit the "订单未找到,忽略" path and silently drop a paid charge (the same failure class the fixtures in this PR were written to regress). Inserting the pending order first (and cleaning up / leaving it pending on link failure) keeps the webhook resolvable.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription_payment_airwallex.go` around lines 159 - 182, Move the pending SubscriptionOrder creation and order.Insert call before genAirwallexSubscriptionLink in the subscription payment flow. If link generation fails, retain or clean up the inserted pending order according to existing conventions, while ensuring successful checkouts always have a resolvable local order for billing_checkout.completed.middleware/cors.go-16-20 (1)
16-20: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winCredentialed CORS allowlist is compiled in and includes
https://localhost:3000/3001.These entries ship to production, so any page an attacker can get running on the victim's
localhost:3000/3001(dev server, malicious local app, a compromised local tool) can issue cookie-authenticated requests to/api/user/self,/api/token/*, etc. The same list also gates payment return URLs viaTrustedBrowserOrigin. Recommend sourcing this from configuration/env and gating the localhost entries behind non-production mode.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/cors.go` around lines 16 - 20, Update trustedCredentialedOrigins so credentialed CORS origins are sourced from configuration or environment rather than compiled into the binary, and ensure localhost:3000 and localhost:3001 are added only in non-production mode. Preserve the production account portal origin and ensure the same configured allowlist remains available to TrustedBrowserOrigin for payment return-URL validation.middleware/cors.go-36-43 (1)
36-43: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Vary: Originmust be set on the wildcard branch too.The response body/headers differ per origin (echoed origin + credentials vs
*), butVary: Originis only emitted for trusted origins. A shared cache can therefore serve a trusted origin's credentialedAccess-Control-Allow-Originto another origin, or cache*for a trusted origin and break its credentialed flow.🛡️ Proposed fix
origin := c.Request.Header.Get("Origin") if origin != "" { + c.Header("Vary", "Origin") if trustedCredentialedOrigins[origin] { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") - c.Header("Vary", "Origin") } else { c.Header("Access-Control-Allow-Origin", "*") }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/cors.go` around lines 36 - 43, Update the origin-handling branch in the CORS middleware so the wildcard response also sets the `Vary` header to `Origin`. Ensure every non-empty-origin response path, including the trusted credentialed and untrusted wildcard branches, emits `Vary: Origin`.controller/subscription_payment_airwallex.go-442-451 (1)
442-451: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winEnforce Airwallex
x-timestampfreshness before accepting signatures.
verifyAirwallexSignaturehashes the timestamp, but it never checks whether the timestamp is recent; becausex-timestampis in Unix milliseconds, a captured request can be replayed indefinitely. Reject signed headers whose timestamp is outside a short tolerance window, such as ±5 minutes.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription_payment_airwallex.go` around lines 442 - 451, Update verifyAirwallexSignature to parse x-timestamp as a Unix-millisecond value and reject it when it falls outside a short freshness window, such as ±5 minutes from the current time. Return false for empty, malformed, or stale/future timestamps before validating the HMAC, while preserving the existing signature computation for fresh requests.DEPLOY.md-28-28 (1)
28-28: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winPin the runtime base image.
calciumion/new-api:latestmakes redeployments and rollbacks non-reproducible: rebuilding an old binary can silently pull a different upstream image. Use an immutable version or digest and update it deliberately.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@DEPLOY.md` at line 28, Update the Dockerfile.newapi runtime base image reference from calciumion/new-api:latest to an immutable version tag or digest, and ensure future upgrades change this reference deliberately while preserving the existing bin/new-api copy behavior.DEPLOY.md-72-72 (1)
72-72: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winDo not use
docker compose restartfor secret rotation.Restarting an existing container does not reapply changed environment variables. Require
docker compose up -d --force-recreate(or equivalent) after editing.env.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@DEPLOY.md` at line 72, Update the “Secrets rotation” guidance in DEPLOY.md to remove docker compose restart as an acceptable action and require docker compose up -d --force-recreate for new-api after editing /opt/newapi/.env, or an equivalent container recreation command.Dockerfile-22-22 (1)
22-22: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winDo not disable Go module checksum verification in the build settings.
Both the Docker builder and the documented deploy-time env globally set
GOSUMDB=off, which bypasses Go’s checksum-database integrity checks. Remove the global flag fromDockerfile#L22and document a trusted checksum-verification path inDEPLOY.md#L49-L50instead; use scopedGONOSUMDBonly if some modules cannot reach the checksum database.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Dockerfile` at line 22, Remove the global GOSUMDB=off setting from Dockerfile line 22 while preserving the other Go build environment variables. Update DEPLOY.md lines 49-50 to document a trusted checksum-verification configuration, using scoped GONOSUMDB only for modules that cannot access the checksum database; no direct Dockerfile change is needed beyond removing GOSUMDB.model/subscription_billing_customer_test.go-5-25 (1)
5-25: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winUse
require/assertin the new backend tests.
model/subscription_billing_customer_test.go#L5-L25: replace setup and fatalt.Fatal/t.Fatalfassertions withrequire.model/subscription_plan_by_price_test.go#L5-L44: replace setup and fatal assertions withrequire.model/subscription_supersede_test.go#L9-L93: replace setup and fatal assertions withrequire; useassertfor independent postconditions where appropriate.As per coding guidelines, new Go backend tests must use
testify/requirefor setup and fatal assertions andtestify/assertfor non-fatal checks.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription_billing_customer_test.go` around lines 5 - 25, Replace fatal test assertions and setup checks in model/subscription_billing_customer_test.go lines 5-25 and model/subscription_plan_by_price_test.go lines 5-44 with testify/require calls. In model/subscription_supersede_test.go lines 9-93, use require for setup and assertions that must stop execution, and assert for independent postconditions; add or reuse the appropriate testify imports.Source: Coding guidelines
model/subscription.go-394-410 (1)
394-410: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winFail closed on duplicate Airwallex price mappings.
This lookup silently selects the lowest-ID plan when two rows share a price ID. A duplicated configuration can therefore grant the entitlement for a different plan than the charged Airwallex price. Enforce uniqueness for non-empty IDs in plan writes and return an error if this lookup finds multiple rows.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription.go` around lines 394 - 410, Update GetSubscriptionPlanByAirwallexPriceId to detect multiple matching SubscriptionPlan rows and return an error instead of selecting the lowest-ID result; preserve nil results for no matches or blank IDs. Enforce uniqueness of non-empty airwallex_price_id values in the SubscriptionPlan write/create/update paths, while allowing empty IDs to remain non-unique.controller/subscription.go-280-286 (1)
280-286: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy liftPreserve “omitted” versus “explicitly clear” field semantics.
Both sites treat
""as absent, so successful API calls cannot clear existing values.
controller/subscription.go#L280-L286: use a presence-aware request field so an explicit emptyairwallex_price_idcan disable that billing mapping while omission preserves it.model/user.go#L522-L538: use presence-aware update fields so admins can explicitly clearusername,display_name, orremarkwithout omitted fields erasing stored values.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription.go` around lines 280 - 286, Preserve omitted-versus-explicit-empty semantics at both update sites: in controller/subscription.go lines 280-286, make the AirwallexPriceId request field presence-aware so omission leaves the existing mapping unchanged while an explicit empty value clears it; in model/user.go lines 522-538, make username, display_name, and remark update fields presence-aware so omitted fields are preserved and explicitly empty values clear the stored values.model/subscription.go-166-166 (1)
166-166: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winAdd
airwallex_price_idto the SQLitesubscription_plansmigration.
ensureSubscriptionPlanTableSQLitecreates/updatessubscription_plansseparately, and the current required column list omits this new field. Add the column to both the initialCREATE TABLEDDL and the required upgrade list, usingALTER TABLE ... ADD COLUMN.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription.go` at line 166, Add the Airwallex price ID column to the SQLite subscription plan schema: update ensureSubscriptionPlanTableSQLite’s initial CREATE TABLE DDL and its required upgrade-column list, and add missing columns through ALTER TABLE ... ADD COLUMN using the existing migration pattern.Source: Coding guidelines
model/subscription.go-1361-1376 (1)
1361-1376: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winMake the billing-customer save atomic.
AirwallexBillingCustomer.user_idhas auniqueIndex, but the read/create sequence atmodel/subscription.go:1366-1371lets concurrent checkouts create customers and then hit a duplicate-key error onCreateafter Airwallex has already allocated the billing customer. Replace it with a GORM-backed upsert onuser_id, e.g. usingclause.OnConflict { Columns: []clause.Column{{Name: "user_id"}}, DoUpdates: clause.Assignments(map[string]any{"customer_id": true}) }.Create(...).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription.go` around lines 1361 - 1376, Replace the read-then-create/update sequence in SaveAirwallexBillingCustomerId with a single GORM upsert keyed by user_id, using the existing unique index and OnConflict configuration. Ensure conflicts update customer_id and preserve CreatedAt for inserts, while retaining the current input validation and error propagation.Source: Coding guidelines
service/airwallex/client_test.go-1-131 (1)
1-131: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winNew Airwallex test files don't use testify as required.
Both files are entirely new and use raw
t.Fatalf/manualif err != nilchecks instead oftestify/requireandtestify/assert.
service/airwallex/client_test.go#L1-L131: replace fatal setup/assertion checks (e.g. lines 55-61, 69-72, 82-85) withrequire.NoError/require.Equal, and non-fatal checks withassert.*.service/airwallex/billing_test.go#L1-L168: same replacement across all assertions (e.g. lines 17-25, 64-72, 90-100).As per coding guidelines: "New or substantially rewritten Go backend tests must use
testify/requirefor setup and fatal assertions andtestify/assertfor non-fatal checks."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/airwallex/client_test.go` around lines 1 - 131, Replace manual checks throughout service/airwallex/client_test.go lines 1-131 and service/airwallex/billing_test.go lines 1-168 with testify assertions: use require.NoError and other require methods for setup or fatal conditions, and assert methods for non-fatal validations. Add the testify/require and testify/assert imports while preserving each test’s existing expectations and coverage.Source: Coding guidelines
service/airwallex/client.go-42-78 (1)
42-78: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winLock held across the entire Airwallex login HTTP call serializes all Airwallex traffic.
tokenMuis acquired at function entry and released only viadefer, so it stays held for the full network round-trip to/authentication/login. Since every caller ofdo()goes throughgetToken()first, a slow or hung Airwallex login endpoint blocks all concurrent Airwallex operations (webhooks, checkouts, cancellations, price switches) one at a time for up to the 30s client timeout — a cascading stall under load or during an Airwallex outage.🔒 Proposed fix: release the lock before the network call (double-checked locking)
func getToken() (string, error) { tokenMu.Lock() - defer tokenMu.Unlock() if cachedToken != "" && time.Now().Before(tokenExpiry) { - return cachedToken, nil + token := cachedToken + tokenMu.Unlock() + return token, nil } + tokenMu.Unlock() + req, err := http.NewRequest(http.MethodPost, setting.AirwallexApiBase+"/api/v1/authentication/login", nil) ... if loginResp.Token == "" { return "", errors.New("airwallex login returned empty token") } + tokenMu.Lock() cachedToken = loginResp.Token tokenExpiry = time.Now().Add(tokenLifetime) + tokenMu.Unlock() return cachedToken, nil }This allows brief duplicate concurrent logins near expiry (harmless — Airwallex tokens aren't single-use) while preventing a slow login from stalling unrelated in-flight requests.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/airwallex/client.go` around lines 42 - 78, Update getToken so tokenMu is held only while checking cachedToken/tokenExpiry and while storing a newly fetched token, not during HTTP request creation, execution, body reading, or response parsing. Use double-checked locking before publishing the refreshed token, preserving cached-token reuse and allowing concurrent login attempts without serializing Airwallex operations.controller/user.go-207-215 (1)
207-215: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
EmailLoginCodeTTLis never enforced — it only formats the email text.The code's real lifetime comes from
common.VerifyCodeWithKey, which compares againstVerificationValidMinutes*60(10 min), notEmailLoginCodeTTL. An operator loweringEmailLoginCodeTTLto 300 gets an email saying "expires in 5 minutes" while the code stays valid for 10. Either drop the setting or thread it into verification.🐛 Suggested direction
Add a TTL-aware verify in
common/verification.goand call it here:// common/verification.go func VerifyCodeWithKeyTTL(key, code, purpose string, ttlSeconds int) bool { verificationMutex.Lock() defer verificationMutex.Unlock() value, ok := verificationMap[purpose+key] if !ok || int(time.Since(value.time).Seconds()) >= ttlSeconds { return false } return subtle.ConstantTimeCompare([]byte(code), []byte(value.code)) == 1 }then in
VerifyEmailLoginCode:common.VerifyCodeWithKeyTTL(req.Email, req.Code, common.EmailLoginPurpose, common.EmailLoginCodeTTL).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/user.go` around lines 207 - 215, Enforce common.EmailLoginCodeTTL during email-code verification instead of only using it in the expiration message. Add a TTL-aware verification helper in common/verification.go alongside VerifyCodeWithKey, then update VerifyEmailLoginCode to call it with req.Email, req.Code, common.EmailLoginPurpose, and common.EmailLoginCodeTTL while preserving the existing constant-time comparison and invalid-code behavior.router/api-router.go-16-21 (1)
16-21: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winApplying
CORS()to the whole/apigroup putsAccess-Control-Allow-Origin: *on the entire authenticated surface.
middleware.CORS()falls back toAccess-Control-Allow-Origin: *for any origin outsidetrustedCredentialedOrigins, so admin,/api/option(RootAuth),/api/channel, and/api/tokenresponses now all become cross-origin readable. Cookie auth is still protected (browsers reject credentials with*), but theaccess_tokenthis PR starts returning is anAuthorization-header credential, which*does not restrain — any page can script authenticated calls once it obtains one.The stated need is only
/api/user/login/*,/api/token/*,/api/user/self. Either scope the middleware to those groups, or drop the*fallback so only allowlisted origins get a CORS header. Note the OPTIONS short-circuit also now precedesGlobalAPIRateLimit()for every/apipath.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@router/api-router.go` around lines 16 - 21, Restrict CORS application in the apiRouter setup to the required authenticated endpoints—/api/user/login/*, /api/token/*, and /api/user/self—instead of applying middleware.CORS() to the entire /api group. Preserve preflight handling for those routes while ensuring admin, /api/option, and /api/channel remain without the wildcard CORS fallback and continue through GlobalAPIRateLimit().common/verification.go-87-108 (1)
87-108: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winUnbounded in-memory email-login state. Both maps are keyed by an attacker-supplied email and have no expiry sweep, so abandoned sign-in attempts accumulate for the process lifetime.
verificationMapalready prunes viaremoveExpiredPairs; these two do not.
common/verification.go#L87-L108: store a timestamp with each attempt count and prune entries older than the code lifetime when the map exceeds a size threshold, mirroringremoveExpiredPairs.controller/user.go#L171-L174:emailLoginLastSentis only deleted on successful verification — prune entries older thanEmailLoginCodeResendCooldownon write, or replace it with a TTL cache.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@common/verification.go` around lines 87 - 108, The verification attempt and resend-tracking maps retain attacker-supplied email entries indefinitely. In common/verification.go lines 87-108, update IncrementAttemptsAndMaybeInvalidate and ResetAttempts to store attempt timestamps and, once the map exceeds a size threshold, prune entries older than the verification-code lifetime, mirroring removeExpiredPairs. In controller/user.go lines 171-174, prune emailLoginLastSent entries older than EmailLoginCodeResendCooldown during writes, or replace the map with an equivalent TTL cache.controller/user.go-278-302 (1)
278-302: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftConcurrent verifications with the same code can create duplicate users.
VerifyCodeWithKey(Line 264) andDeleteKey(Line 274) are separate critical sections, so two in-flight requests carrying the same valid code both pass, both miss the lookup, and bothInsert.User.Emailis declaredgorm:"index"— not unique — so nothing stops two rows sharing the address, after which everyFirst(&user)by email becomes nondeterministic. Use a single find-or-create guarded by a transaction (or add a unique constraint on🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/user.go` around lines 278 - 302, Make the verified-email user creation path around VerifyCodeWithKey, DeleteKey, and the post-insert lookup concurrency-safe: use one transaction for the existence check and creation, or enforce a unique email constraint and handle duplicate-key errors by re-selecting the existing user. Ensure concurrent requests with the same code cannot leave duplicate User rows and preserve the existing response/error behavior.controller/user.go-118-125 (1)
118-125: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winEmail case handling is inconsistent between V0 and V1, and case-sensitivity differs per database.
LoginByEmailonly trims, whileRequestEmailLoginCode/VerifyEmailLoginCodelowercase (Lines 190, 257). On PostgreSQL and SQLite (binary collation)WHERE email = ?is case-sensitive, soFoo@x.comvia V0 andfoo@x.comvia V1 resolve to two different accounts; on MySQL's default_cicollation they resolve to one. Normalize identically in both handlers.🐛 Proposed fix
- req.Email = strings.TrimSpace(req.Email) + req.Email = strings.TrimSpace(strings.ToLower(req.Email))As per coding guidelines, "All database code must remain compatible with SQLite, MySQL >= 5.7.8, and PostgreSQL >= 9.6."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/user.go` around lines 118 - 125, Normalize the email consistently in LoginByEmail by trimming whitespace and converting it to lowercase before validation and the model.DB lookup, matching the normalization already used by RequestEmailLoginCode and VerifyEmailLoginCode. Preserve the existing invalid-parameter handling and query behavior after normalization.Source: Coding guidelines
controller/user.go-234-238 (1)
234-238: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy liftBlocking SMTP call on the request thread, and the email address is written to logs.
Two problems here:
common.SendEmailis synchronous andnet/smtp/tls.Dialare used without any deadline, so a hung SMTP endpoint pins this handler (and the client) until the OS TCP timeout. The comment says "fire-and-forget" but the call is not. The PR summary also describes this as asynchronous delivery.- The failure log embeds the full address; the same pattern appears on Lines 140 and 299. Logging user email identifiers is a privacy/compliance exposure — mask or log the user id instead.
🔒 Proposed fix
- if err := common.SendEmail(subject, req.Email, content); err != nil { - common.SysLog(fmt.Sprintf("RequestEmailLoginCode SendEmail failed for %s: %v", req.Email, err)) - } + go func(to, subject, content string) { + if err := common.SendEmail(subject, to, content); err != nil { + common.SysLog(fmt.Sprintf("RequestEmailLoginCode SendEmail failed for %s: %v", maskEmail(to), err)) + } + }(req.Email, subject, content)where
maskEmailkeeps only the first character and the domain. A dial/read deadline incommon.SendEmailis still worth adding regardless.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/user.go` around lines 234 - 238, Make the email delivery path in the request handler genuinely fire-and-forget by moving common.SendEmail out of the request thread while preserving failure logging. Replace full email addresses in the failure logs at the shown location and the corresponding logging sites near lines 140 and 299 with maskEmail output or a user identifier. Add appropriate dial/read deadlines inside common.SendEmail so SMTP operations cannot hang indefinitely.common/constants.go-94-97 (1)
94-97: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winDefaulting the email-login master switch to
trueopens passwordless account creation on every existing deployment.
EmailOnlyLoginEnabled = truemeans any instance that upgrades immediately exposes/api/user/login/email/request-codeand/verify-code, which auto-create accounts (withQuotaForNewUsercredits) for any address that can receive mail. Every other auth provider in this codebase defaults off (GitHubOAuthEnabled,TelegramOAuthEnabled, …). Recommend defaulting tofalseand enabling it explicitly for the JINN deployment.🔒 Proposed change
-var EmailOnlyLoginEnabled = true +var EmailOnlyLoginEnabled = false🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@common/constants.go` around lines 94 - 97, Change the default value of EmailOnlyLoginEnabled to false so existing deployments do not enable email-based account creation on upgrade. Preserve explicit configuration support for enabling it in the JINN deployment, consistent with the other authentication provider switches.
🟡 Minor comments (6)
service/subscription_recurring_test.go-17-20 (1)
17-20: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winRestore the global registry with
t.Cleanup.These tests permanently replace
recurringChargers; a fatal assertion or later test can observe an empty registry and lose pre-registered chargers. Snapshot it before setup and restore it viat.Cleanup.Proposed fix
+func isolateRecurringChargers(t *testing.T) { + t.Helper() + recurringChargersMu.Lock() + previous := recurringChargers + recurringChargers = map[string]RecurringCharger{} + recurringChargersMu.Unlock() + + t.Cleanup(func() { + recurringChargersMu.Lock() + recurringChargers = previous + recurringChargersMu.Unlock() + }) +} + func TestChargeDueAgreementSubscriptionsNoopWhenEmpty(t *testing.T) { - recurringChargersMu.Lock() - recurringChargers = map[string]RecurringCharger{} - recurringChargersMu.Unlock() + isolateRecurringChargers(t) ChargeDueAgreementSubscriptions() // must not panic — the HK/Airwallex steady state }Also applies to: 24-27, 46-48
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/subscription_recurring_test.go` around lines 17 - 20, Update TestChargeDueAgreementSubscriptionsNoopWhenEmpty and the other affected test setup blocks to snapshot the existing recurringChargers registry before replacing it, then register a t.Cleanup callback that restores the snapshot under recurringChargersMu; keep the test’s empty-registry setup unchanged during execution.DEPLOY.md-23-23 (1)
23-23: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winSpecify a language for the directory-tree fence.
Use
texthere to satisfy Markdown linting and improve renderer behavior.Proposed fix
-``` +```text🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@DEPLOY.md` at line 23, Specify the text language on the directory-tree fenced code block in DEPLOY.md by changing its opening fence to use text, while preserving the existing block contents.Source: Linters/SAST tools
model/subscription_billing_customer_test.go-6-10 (1)
6-10: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winReset the test’s persisted state.
AutoMigratedoes not clear an existing mapping for user4242, so test ordering or repeated execution can fail the initial-empty assertion. Initialize the table state before querying it.As per coding guidelines, backend tests must initialize database state explicitly.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription_billing_customer_test.go` around lines 6 - 10, Reset the persisted AirwallexBillingCustomer state before the initial lookup in this test: after AutoMigrate and before GetAirwallexBillingCustomerId(4242), explicitly clear or initialize the table using the test database setup mechanism. Preserve the assertion that user 4242 returns an empty ID.Source: Coding guidelines
service/airwallex/client_test.go-64-74 (1)
64-74: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winManual state restore is skipped on assertion failure, leaking global config into later tests.
t.Fatalfat line 71 exits before line 73 restoressetting.AirwallexApiKey, so a failure here corruptsAirwallexApiKeyfor every test run afterward, obscuring the real failure.🧹 Proposed fix: restore via t.Cleanup
func TestLoginFailureSurfaced(t *testing.T) { var logins int32 mockServer(t, &logins, func(w http.ResponseWriter, r *http.Request) {}) setting.AirwallexApiKey = "wrong" + t.Cleanup(func() { setting.AirwallexApiKey = "key" }) ResetTokenCache() _, err := CreateCustomer("req-1", "7", "") if err == nil || !strings.Contains(err.Error(), "login failed") { t.Fatalf("expected login failure, got %v", err) } - setting.AirwallexApiKey = "key" }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/airwallex/client_test.go` around lines 64 - 74, Update TestLoginFailureSurfaced to register restoration of setting.AirwallexApiKey through t.Cleanup immediately after changing it to "wrong", and remove the manual reset at the end so cleanup runs even when the assertion calls t.Fatalf.common/constants.go-99-103 (1)
99-103: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winDoc comment contradicts the value.
The comment says "Default true preserves V0 behaviour during the V1 rollout", but the variable is
false. Either fix the comment or the default.📝 Proposed fix
-// Default true preserves V0 behaviour during the V1 rollout. Flip to false -// once all desktop clients ship with V1 code-entry support. +// Default false: V0 is opt-in only. Operators enable it temporarily for +// clients that have not yet shipped V1 code-entry support. var EmailOnlyLoginAutoCreateNoVerify = false🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@common/constants.go` around lines 99 - 103, Resolve the contradiction between the doc comment and EmailOnlyLoginAutoCreateNoVerify by making the documented default match the intended rollout behavior: either set the variable to true to preserve the stated V0 default, or revise the comment to accurately describe a false default and its implications.common/verification.go-43-56 (1)
43-56: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick winModulo bias, and the fallback emits non-numeric codes.
digits[int(b[i])%10]is biased (256 % 10 ≠ 0), so digits 0–5 are ~4% more likely than 6–9 — a small but avoidable entropy loss on a 6-digit sign-in OTP. Also, therand.Readfallback returns a UUID-derived hex string, which violates the "digit-only" contract the client's OTP input relies on; sincecrypto/rand.Readnever returns a partial read without error, failing closed is cleaner.🔒 Proposed fix using rejection-free `rand.Int`
func GenerateNumericVerificationCode(length int) string { - const digits = "0123456789" - b := make([]byte, length) - if _, err := rand.Read(b); err != nil { - return GenerateVerificationCode(length) - } - for i := range b { - b[i] = digits[int(b[i])%10] - } - return string(b) + const digits = "0123456789" + b := make([]byte, length) + for i := range b { + n, err := rand.Int(rand.Reader, big.NewInt(10)) + if err != nil { + return "" + } + b[i] = digits[n.Int64()] + } + return string(b) }(requires
math/big; callers must treat""as a generation failure)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@common/verification.go` around lines 43 - 56, Update GenerateNumericVerificationCode to eliminate modulo bias by generating each digit with crypto/rand.Int over a bound of 10, adding the required math/big dependency. On any randomness failure, return an empty string instead of calling GenerateVerificationCode, while preserving the digit-only output contract for successful generation.
🧹 Nitpick comments (6)
controller/subscription_payment_airwallex_billing_test.go (1)
371-377: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRedundant conversion:
nis alreadyint64.♻️
- return int64(n) + return n🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription_payment_airwallex_billing_test.go` around lines 371 - 377, Remove the redundant int64 conversion in countRenewalOrders and return the already-typed n value directly.controller/subscription_payment_airwallex.go (2)
760-765: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUnused
rawparameter.
handleAirwallexIntentSucceedednever readsraw; the payload is rebuilt fromevent.Data.Object. Drop the parameter and the argument at Line 487.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription_payment_airwallex.go` around lines 760 - 765, Remove the unused raw parameter from handleAirwallexIntentSucceeded and update its call site at the referenced handler to stop passing the raw payload argument, preserving the existing event-based processing.
281-295: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valuePartial cancellation is reported as a total failure.
If subscription N fails after N-1 were cancelled, the user sees "取消订阅失败,请稍后重试" and a retry will re-cancel already-cancelled subscriptions. Consider continuing the loop, tracking failures, and reporting the counts.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription_payment_airwallex.go` around lines 281 - 295, Update the subscription cancellation loop to continue processing remaining subscriptions when Airwallex.CancelBillingSubscription fails instead of returning immediately. Track cancelled and failed counts, preserve error logging for each failure, and return a response that reports both counts so retries do not appear to be an all-or-nothing operation.controller/subscription_payment_airwallex_planswitch_test.go (1)
41-227: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueNew Go tests should use
testify/requirefor fatal assertions instead ofif err != nil { t.Fatal(...) }. Both new test files mix the two styles; the guideline requiresrequirefor setup/fatal assertions andassertfor non-fatal checks.
controller/subscription_payment_airwallex_planswitch_test.go#L41-L227: replace theif err := ...; err != nil { t.Fatal(err) }blocks and theif got != want { t.Fatalf(...) }assertions withrequire.NoError/require.Equal.controller/subscription_payment_airwallex_billing_test.go#L141-L152: convertloadEvent's twot.Fatal(err)blocks torequire.NoError(t, err).As per coding guidelines, "New or substantially rewritten Go backend tests must use
testify/requirefor setup and fatal assertions andtestify/assertfor non-fatal checks".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription_payment_airwallex_planswitch_test.go` around lines 41 - 227, Update controller/subscription_payment_airwallex_planswitch_test.go lines 41-227 to use testify/require: replace fatal setup error checks with require.NoError and fatal equality assertions with require.Equal, preserving existing messages where needed; use assert only for non-fatal checks. Also update loadEvent in controller/subscription_payment_airwallex_billing_test.go lines 141-152 by replacing both t.Fatal(err) error checks with require.NoError(t, err).Source: Coding guidelines
service/airwallex/client.go (1)
42-131: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winNo
context.Contextpropagation for outbound Airwallex calls.
getToken()anddo()usehttp.NewRequestrather thanhttp.NewRequestWithContext, so callers (e.g. the webhook handler, which has its own request context) can't bound or cancel in-flight Airwallex calls beyond the shared package-levelHTTPClient30s timeout.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/airwallex/client.go` around lines 42 - 131, The Airwallex request helpers do not propagate caller cancellation or deadlines. Update getToken and do to accept a context.Context and create their outbound requests with http.NewRequestWithContext; pass the caller’s context through do to getToken and update all call sites to provide the appropriate request context, preserving existing authentication and response handling.i18n/keys.go (1)
82-82: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse or remove the new email login expiry key.
MsgEmailLoginCodeExpiredis not referenced anywhere, whileVerifyEmailLoginCodestill maps expired codes back to the same “invalid” response as wrong codes. Either return this key for expired codes or remove the key and its translations.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@i18n/keys.go` at line 82, Update VerifyEmailLoginCode to return MsgEmailLoginCodeExpired when the login code is expired, preserving the existing invalid response for incorrect codes; alternatively, remove the unused MsgEmailLoginCodeExpired constant and all corresponding translations if expiry should remain mapped to invalid.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 4640fb22-dfe0-427f-9b09-38707d502069
⛔ Files ignored due to path filters (1)
web/default/bun.lockis excluded by!**/*.lock
📒 Files selected for processing (38)
CLAUDE.mdDEPLOY.mdDockerfilecommon/constants.gocommon/email.gocommon/verification.gocontroller/subscription.gocontroller/subscription_payment_airwallex.gocontroller/subscription_payment_airwallex_billing_test.gocontroller/subscription_payment_airwallex_changeplan_test.gocontroller/subscription_payment_airwallex_planswitch_test.gocontroller/subscription_payment_airwallex_test.gocontroller/testdata/awx_billing_checkout_completed.jsoncontroller/testdata/awx_billing_checkout_completed_slim.jsoncontroller/testdata/awx_invoice_paid.jsoncontroller/user.goi18n/keys.goi18n/locales/en.yamli18n/locales/zh-CN.yamli18n/locales/zh-TW.yamlmiddleware/cors.gomodel/main.gomodel/option.gomodel/subscription.gomodel/subscription_billing_customer_test.gomodel/subscription_plan_by_price_test.gomodel/subscription_supersede_test.gomodel/topup.gomodel/user.gorouter/api-router.goservice/airwallex/billing.goservice/airwallex/billing_test.goservice/airwallex/client.goservice/airwallex/client_test.goservice/subscription_recurring.goservice/subscription_recurring_test.goservice/subscription_reset_task.gosetting/payment_airwallex.go
The portal moved from account.jinn.ccwu.cc to account.jinnhq.com but the allowlist was not updated. TrustedBrowserOrigin therefore rejected the portal's return_url, airwallexReturnUrl silently fell back to the admin console, and paying users landed on /sign-in?redirect=/wallet instead of the confirmation page. Activation was unaffected (it runs on the webhook), which is why this went unnoticed. Adds a test pinning both the live and legacy portal origins.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
middleware/cors.go (2)
34-59: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winSet
Vary: Originfor every origin-bearing response.The response differs by
Origin: trusted origins receive a specific origin and credentials, while others receive*. SettingVaryonly in the trusted branch can let a shared cache reuse a wildcard response for a credentialed request, breaking browser API calls.Proposed fix
if origin != "" { + c.Header("Vary", "Origin") if trustedCredentialedOrigins[origin] { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") - c.Header("Vary", "Origin") } else {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/cors.go` around lines 34 - 59, Update CORS so every response with a non-empty Origin request header sets Vary: Origin, not only responses matching trustedCredentialedOrigins. Keep the existing trusted-origin and wildcard Access-Control-Allow-Origin behavior unchanged.
55-57: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winAbort only actual CORS preflight requests.
This currently intercepts every
OPTIONSrequest, even withoutOriginorAccess-Control-Request-Method, preventing any legitimate APIOPTIONShandler from running.Proposed fix
-if c.Request.Method == http.MethodOptions { +if c.Request.Method == http.MethodOptions && + origin != "" && + c.Request.Header.Get("Access-Control-Request-Method") != "" { c.AbortWithStatus(http.StatusNoContent) return }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/cors.go` around lines 55 - 57, Update the OPTIONS handling in the CORS middleware to abort only when the request includes both CORS preflight indicators: an Origin header and an Access-Control-Request-Method header. Allow other OPTIONS requests to continue to the downstream handler.
🧹 Nitpick comments (1)
middleware/cors_test.go (1)
11-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd tests for the actual
CORS()middleware behavior.These tests only exercise the allowlist helper. Add
httptest/Gin cases covering trusted credentialed responses, wildcard responses,Vary: Origin, allowed headers, andOPTIONSpreflight handling; otherwise the main changed behavior inmiddleware/cors.goremains unprotected.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/cors_test.go` around lines 11 - 34, Extend the CORS tests beyond TrustedBrowserOrigin by adding Gin/httptest coverage for the CORS() middleware. Verify trusted origins receive credentialed responses, untrusted origins receive wildcard responses, responses include Vary: Origin and the configured allowed headers, and OPTIONS requests are handled correctly as preflight requests.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@middleware/cors_test.go`:
- Line 3: Update the assertions in the tests in middleware/cors_test.go to use
testify/assert instead of t.Errorf, including the assertions at the referenced
import and test lines; add the required testify/assert import and preserve the
existing non-fatal assertion behavior.
---
Outside diff comments:
In `@middleware/cors.go`:
- Around line 34-59: Update CORS so every response with a non-empty Origin
request header sets Vary: Origin, not only responses matching
trustedCredentialedOrigins. Keep the existing trusted-origin and wildcard
Access-Control-Allow-Origin behavior unchanged.
- Around line 55-57: Update the OPTIONS handling in the CORS middleware to abort
only when the request includes both CORS preflight indicators: an Origin header
and an Access-Control-Request-Method header. Allow other OPTIONS requests to
continue to the downstream handler.
---
Nitpick comments:
In `@middleware/cors_test.go`:
- Around line 11-34: Extend the CORS tests beyond TrustedBrowserOrigin by adding
Gin/httptest coverage for the CORS() middleware. Verify trusted origins receive
credentialed responses, untrusted origins receive wildcard responses, responses
include Vary: Origin and the configured allowed headers, and OPTIONS requests
are handled correctly as preflight requests.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 18036ee6-0ff7-44d4-820e-e3a6a8e33656
📒 Files selected for processing (2)
middleware/cors.gomiddleware/cors_test.go
| @@ -0,0 +1,34 @@ | |||
| package middleware | |||
|
|
|||
| import "testing" | |||
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use testify/assert for these non-fatal assertions.
The repository guideline requires new Go backend tests to use testify/assert or testify/require, rather than t.Errorf.
Proposed fix
import "testing"
+import "github.com/stretchr/testify/assert"
- if !TrustedBrowserOrigin(origin) {
- t.Errorf("origin %s must be trusted; payment return URLs from it are silently discarded otherwise", origin)
- }
+ assert.True(t, TrustedBrowserOrigin(origin), "origin %s must be trusted", origin)
- if TrustedBrowserOrigin(origin) {
- t.Errorf("origin %q must NOT be trusted", origin)
- }
+ assert.False(t, TrustedBrowserOrigin(origin), "origin %q must NOT be trusted", origin)Also applies to: 16-17, 30-31
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@middleware/cors_test.go` at line 3, Update the assertions in the tests in
middleware/cors_test.go to use testify/assert instead of t.Errorf, including the
assertions at the referenced import and test lines; add the required
testify/assert import and preserve the existing non-fatal assertion behavior.
Source: Coding guidelines
Charging immediately cannot be made to work on Airwallex Billing, verified
live on 2026-07-30:
- IMMEDIATE_CHARGE_AND_RESET_CYCLE + PRORATED charges the annual price in
full and returns the unused time as a credit note that REFUNDS to the
card, rather than discounting the new invoice. That refund failed with
amount_above_limit because refunds draw on the merchant CNY balance,
which was empty. The customer was left out of pocket with no signal.
- Charging the prorated difference as a one-off and suppressing the next
cycle with trial_end_at does not work: Billing accepts trial_end_at with
a 200 and silently ignores it, so that route would double-charge.
DEFER_CHARGE_AND_KEEP_CYCLE + NONE needs neither a refund nor a discount:
the customer keeps the period they paid for and is charged the annual price
at the billing date they already expected. No money moves at switch time, so
the whole failure class disappears.
The endpoint now returns effective_at so the portal can state when annual
starts, and a second click reports the switch as scheduled rather than
claiming the user is already on the plan.
The portal could only draw a bar for a plan with a metered pool, which is
Pro alone: Free and Plus carry total_amount 0 and are capped by a request
rate limit whose counter lives in Redis with no read endpoint. This adds
one endpoint that reports whichever meters apply to the caller — requests
for everyone, the included pool on top for a plan that has one.
Two things the naive reading of the counter gets wrong:
- The stored entries are not UTC. recordRedisRequest formats a local
clock under a literal "Z", so parsing an entry as UTC would skew every
reset time by the server's offset — eight hours on a China host.
rateLimitWindowStart compares against a now formatted the same way,
which is what checkRedisRateLimit already does.
- LLEN is the wrong number. The list is trimmed by length, never by age,
and its TTL refreshes on every write, so a steady Pro user's key holds
2000 entries spanning days. An LLEN bar would read 100% for someone
nobody is throttling. countRateLimitWindow counts only live entries.
An evicted key reads as zero, never as an error: Redis runs allkeys-lru at
128mb, so losing the key is expected rather than exceptional.
Verified end to end against a running instance: a Pro with an exhausted
pool returns requests + included at 100% with the pool's own reset_at, and
a group missing from ModelRequestRateLimitGroup falls back to the global
limit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A usage bar that fills with no explanation is worse than no bar, and the explanation a user got was: 订阅额度不足或未配置订阅: subscription quota insufficient, need=344000 An internal string, an internal quota unit, no refill date, one language. The bar cannot ship in front of that. The refusal now reads from the same next_reset_time the bar draws, so both surfaces name the same day, and it goes through the existing i18n layer so a 繁體 user gets 繁體. Three states are distinguished: exhausted with a refill date, exhausted with no date (a plan that never resets), and no active subscription — a lapsed plan must not promise a refill date that belongs to a subscription which already ended. The cause is logged rather than shown. Reserve() runs mid-stream where there is no gin.Context left, so the language is captured on the session at pre-consume time. Verified live in all three locales against a running instance with an exhausted pool. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pointer type tests
…rom the no-date wall - CheckUsageAllowance now logs the cause via logger.LogWarn before each fail-open return (unreadable cost counter, image-reservation storage failure), so a database outage on user_usage_counters leaves an audit trail instead of silently uncapping the user. The fail-open behaviour itself is unchanged. - UsageLimitMessage no longer substitutes a 'the next cycle' phrase into the dated template when there is no reset date to name. It now selects a separate _no_date message key per kind (mirroring subscription.paused_until vs subscription.paused), so the wall never says anything but the exact date or a plain dateless statement.
PostConsumeQuota sits behind SettleBilling's `relayInfo.Billing != nil` branch, which every relay request takes (PreConsumeBilling sets Billing unconditionally) — so the shadow-accrual block added there never ran, and Free/Plus counters stayed at 0. Also, that block read relayInfo.Usage, which is promoted from an embedded *ClaudeConvertInfo pointer allocated only for Claude-format traffic; reading it panics on every other relay format, i.e. most JINN traffic. Move the accrual into PostTextConsumeQuota (service/text_quota.go), the actual settlement function for token-based traffic, using its `usage` parameter instead of relayInfo.Usage. Extracted as accrueShadowUsage() since PostTextConsumeQuota already builds and passes the right inputs. Verified before shipping: PriceData is a plain value field on RelayInfo (not behind an embedded pointer like Usage was); all PostTextConsumeQuota call sites are single-fire per request (early-return after each call, and callers are disjoint by relay format/route); the call is unconditional regardless of computed quota, so GroupRatio-0 tiers still accrue.
Code-review fix wave, three findings: - setting.GetMonthlyImageLimit now returns (limit, found) so a group absent from the map can be told apart from one explicitly capped at 0. usage_gate.go only enforces the image limit when the group is present, matching the cost limit's existing "absent = uncapped" semantics. Previously every group defaulted to refuse-all until an operator populated the option, 403ing every vision request. - model/usage_counter.go: AddUsage and ReserveImages no longer take a GORM v1 "gorm:query_option"/"FOR UPDATE" no-op (that hint does not exist in vendored gorm.io/gorm v1.25.2 and was silently discarded on every dialect) followed by a full-row Save of a struct read earlier. AddUsage now updates cost_used/requests_used via gorm.Expr arithmetic and touches nothing else, so it can no longer clobber images_used written by a concurrent ReserveImages. ReserveImages inserts the fresh hashes and then applies images_used's increment conditionally (images_used + fresh <= limit) in one UPDATE, checking RowsAffected and rolling back the whole transaction on refusal so nothing is partially reserved. Portable across SQLite, MySQL >= 5.7.8 and PostgreSQL. Also clamps a negative completionTokens in ShadowCost the same way cachedTokens already is, so a broken upstream can't decrement the cumulative counter. - usage_gate.go returns a new usage.images_not_included message (added in all three locales) instead of the dated "refills on" wall when a group's image limit is explicitly 0 — that group never had an allowance to exhaust, so nothing refills. Testing note: the model/service SQLite test harnesses serialize all queries (SetMaxOpenConns(1)), so no goroutine test here can exercise real concurrent-transaction interleaving. The new tests pin the correct sequential behaviour (AddUsage leaves images_used untouched, the conditional increment's exact-limit boundary) rather than asserting anything about concurrency itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The portal card showed a Plus subscriber two dates that disagreed: renewal on the 8th, usage refill on the 1st. A user who pays on the 8th has a fair question about why their month resets a week earlier. Paying tiers now anchor to the subscription's own billing anniversary; Free, which has no billing date to anchor to, keeps the calendar month. Two traps this had to avoid. An unmetered row carries next_reset_time 0 — that field belongs to a metered pool it does not have — so the anniversary is computed from start_time rather than read. And it walks months rather than using end_time, because an annual Plus plan runs a year end to end and the usage cycle must stay monthly inside it. Day-of-month is clamped: Go's AddDate turns 31 January plus a month into 3 March, which would drift a subscriber's cycle every short month. All three call sites — the gate, the accrual and the portal endpoint — now resolve the anchor through one shared helper. They must agree or the writer and reader land on different counter rows, which is the failure this feature already shipped twice. The new test proves they agree AND that the anchored cycle is genuinely not the calendar month, so it cannot pass while the anchor is being ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cancelling stopped the money and told nobody. SubscriptionCancelAirwallex
called Airwallex and wrote nothing locally; the subscription.cancelled
webhook was logged and dropped ("term-end downgrade is engine-native").
That reasoning was nearly right — ExpireDueSubscriptions does handle the
downgrade — but it missed that the renewal fact itself then has nowhere to
live, so the portal offered the cancel button again on every reload and
errored on the second click.
It cannot be re-derived on demand. Airwallex Billing has no
cancel_at_period_end: its subscription goes straight to CANCELLED while
the local row must stay active until EndTime, and once those diverge the
processor's status no longer answers "will this renew?" for the row.
Add UserSubscription.CancelledAt, deliberately orthogonal to Status.
Access and renewal are different facts: every active-set query here keys
on status = "active", so folding this into Status would revoke the
paid-for remainder immediately and could trip the group downgrade — the
opposite of the stated policy.
Three write paths, because one is never enough:
- the cancel endpoint, for the normal case, immediately;
- the subscription.cancelled/unpaid webhook, which also covers cancels
made outside the portal (support acting in the Airwallex dashboard,
or dunning);
- a reconcile pass on the master-node ticker, because webhooks get
dropped and without it one lost delivery leaves a row permanently
claiming it will renew, with nothing able to notice.
All idempotent: the update matches only rows where cancelled_at is still
0, so duplicates are no-ops and the first cancellation time survives.
The reconcile marks only on positive evidence — a successful Airwallex
response containing no ACTIVE subscription. An API error is the absence of
evidence, not evidence of absence; marking there would cancel every live
subscription during an outage. It also skips source="admin" rows, which
have no Airwallex subscription behind them and would otherwise look
identical to a cancelled one.
Clicking cancel when Airwallex reports nothing ACTIVE now records the
cancellation and succeeds instead of erroring, which self-heals a row
whose webhook was missed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing in the purchase path supersedes an existing subscription, and the only guard was MaxPurchasePerUser, which is 0 on every JINN plan. So a Plus subscriber who clicked Pro got a SECOND Airwallex subscription alongside the first and both billed — ¥20 and ¥100 together. Never triggered in production, but live for anyone who tried. The gate goes here rather than in the portal. The button is UX; this is the thing that actually protects the customer's card, and a client-side check is bypassable by POSTing directly. Cancelled-but-still-running deliberately does NOT block. That subscription will never bill again, so a fresh purchase leaves exactly one live subscription — that is the resubscribe path, and blocking it would strand a customer who cancelled and changed their mind until their period lapsed. The rule therefore keys on cancelled_at, which only became knowable when cancellation started being persisted. Tier changes are handled by support for now: cancel at Airwallex, buy the other plan. Building the switch properly needs a mid-term credit Airwallex cannot apply to an invoice — it refunds to the card instead, which fails on an empty CNY balance (verified 2026-07-30, a8dc14b). See jinn/JINN_SUBSCRIPTION_KNOWN_ISSUES.md for the full reasoning and for the immediate-switch design that was built and rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 17
🧹 Nitpick comments (3)
service/subscription_pause.go (1)
41-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReplace the substring match with a sentinel error.
causeIsMissingSubscriptiondepends on the exact text "no active subscription" produced by the model layer. If that text is reworded, or if the error is wrapped with a different message, the check returns false and the user sees the "your plan is paused, it refills on X" copy instead of "you have no subscription". Nothing fails loudly when that happens.Export a sentinel from the model package and compare with
errors.Is. The comment already identifies this as the intended direction.♻️ Proposed refactor
In the model package, where the error is produced:
// ErrNoActiveSubscription reports that the user has no active subscription row. var ErrNoActiveSubscription = errors.New("no active subscription")Then:
func causeIsMissingSubscription(cause error) bool { - return cause != nil && strings.Contains(cause.Error(), "no active subscription") + return errors.Is(cause, model.ErrNoActiveSubscription) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/subscription_pause.go` around lines 41 - 46, Replace the string-based check in causeIsMissingSubscription with errors.Is against an exported model-package sentinel named ErrNoActiveSubscription. Define and return or wrap this sentinel where the model reports the missing subscription, preserving detection through wrapped errors and removing the direct error-text dependency.model/usage_counter.go (1)
34-40: 🗄️ Data Integrity & Integration | 🔵 TrivialPlan pruning for
user_image_uploads.The table stores one row per distinct image per user per cycle and nothing deletes past cycles. On a vision-heavy account the row count grows without an upper bound, and the unique index grows with it. Add a retention job that deletes rows with
cycle_startolder than the current cycle, or document the retention owner.The stored
image_hashis a SHA-256 of the image URL. Fordata:URLs the hash is derived from user content, so retention also has a privacy dimension.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/usage_counter.go` around lines 34 - 40, Add a retention job for UserImageUpload that deletes records whose cycle_start is older than the current cycle, and integrate it with the existing scheduled cleanup mechanism. Ensure the cleanup covers both table rows and the associated unique index while preserving current-cycle records.service/text_quota.go (1)
312-330: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winMove the accrual off the synchronous settlement path.
accrueShadowUsageperforms oneSELECTthroughCycleSubscriptionForand one write transaction throughmodel.AddUsage. Both run inline inPostTextConsumeQuota, so every settled request now pays two extra database round trips before the handler returns. The accrual is advisory and already fails open on error, so it does not need to block the response.PostTextConsumeQuotaalready dispatchesperfmetrics.RecordRelaySamplethroughgopool.Go; use the same pattern here.Copy the needed scalars before the goroutine starts, so the goroutine does not read
relayInfoafter the request completes.♻️ Proposed refactor
func accrueShadowUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage) { shadow := ShadowCost( usage.PromptTokens, usage.CompletionTokens, usage.PromptTokensDetails.CachedTokens, relayInfo.PriceData.ModelRatio, relayInfo.PriceData.CompletionRatio, relayInfo.PriceData.CacheRatio, ) - cycleStart, _ := UsageCycle(CycleMonth, CycleSubscriptionFor(relayInfo.UserId), time.Now()) - if err := model.AddUsage(relayInfo.UserId, CycleMonth, cycleStart, shadow, 1); err != nil { - common.SysLog("usage accrual failed: " + err.Error()) - } + userId := relayInfo.UserId + gopool.Go(func() { + cycleStart, _ := UsageCycle(CycleMonth, CycleSubscriptionFor(userId), time.Now()) + if err := model.AddUsage(userId, CycleMonth, cycleStart, shadow, 1); err != nil { + common.SysLog("usage accrual failed: " + err.Error()) + } + }) }The existing tests in
service/usage_shadow_accrual_test.goassert the counter immediately afterPostTextConsumeQuotareturns. If you adopt this change, those tests need a synchronization seam.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/text_quota.go` around lines 312 - 330, Move shadow usage accrual in PostTextConsumeQuota to an asynchronous gopool.Go callback, matching the existing perfmetrics.RecordRelaySample pattern, so settlement does not wait for CycleSubscriptionFor or model.AddUsage. Before launching the goroutine, copy all required scalar values from relayInfo and usage; have the callback perform the existing accrueShadowUsage logic without accessing request-owned relayInfo afterward. Add or reuse a synchronization seam so usage_shadow_accrual_test.go can wait for completion before asserting the counter.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@controller/subscription_payment_airwallex.go`:
- Around line 147-159: Add an atomic pending Airwallex order reservation
immediately before genAirwallexSubscriptionLink, so concurrent checkout requests
cannot both proceed after HasRenewingUserSubscription. Reject or reuse an
unexpired reservation for the user, and release or expire the reservation if
link creation fails; preserve the existing completed-subscription checks and
error responses.
In `@controller/usage.go`:
- Around line 111-119: Update percentUsed to avoid multiplying used by 100
before division, using overflow-safe arithmetic that preserves nearest-integer
rounding and caps valid results at 100. Add a regression test covering counters
near math.MaxInt64 and verify the result remains correct.
In `@middleware/model-rate-limit.go`:
- Around line 202-219: Update recordRedisRequest to store unambiguous UTC
timestamps, and update both rateLimitWindowStart and checkRedisRateLimit to read
the new format while retaining parsing support for existing local-clock entries.
Ensure DST fall-back entries are ordered correctly and do not rely on the
negative-age clamp to determine the window start.
In `@model/subscription_cancel_test.go`:
- Around line 9-188: Replace direct t.Fatal/t.Fatalf setup and fatal assertions
with testify/require, and replace non-fatal t.Errorf checks with testify/assert
throughout model/subscription_cancel_test.go lines 9-188 and
service/subscription_reconcile_test.go lines 52-149. Add or reuse the testify
assertion imports while preserving each test’s existing expectations and control
flow.
In `@model/subscription.go`:
- Around line 913-916: Update runSubscriptionQuotaResetOnce and its subscription
query so reconciliation progresses beyond the first fixed batch, using a durable
cursor or another database-compatible paging strategy that eventually visits
every eligible subscription. Preserve the existing eligibility filters and
ordering, and add a regression test with more than 200 candidates verifying
later subscriptions are reconciled.
- Around line 875-877: Track cancellations by Airwallex subscription ID instead
of user ID: in model/subscription.go:875-877, persist the provider ID and update
only its mapped local row; in
controller/subscription_payment_airwallex.go:296-324 and :568-582, pass each
cancelled sub.Id, including the webhook ID after fallback fetching; in
service/subscription_reconcile.go:61-80, compare mapped local IDs against active
Airwallex IDs before cancelling; in model/subscription_cancel_test.go:9-188, add
coverage for an old cancellation arriving after a replacement subscription
starts.
- Around line 887-895: Update model/subscription.go lines 887-895 in
GetUserIdByAirwallexBillingCustomerId to return (int, error), preserving (0,
nil) only for empty or unknown customer IDs and propagating database lookup
errors. Update controller/subscription_payment_airwallex.go lines 577-580 to
handle the returned error and return it from the webhook handler so failed
lookups produce a retryable failure; adjust callers for the new signature.
- Around line 940-943: Update HasRenewingUserSubscription to restrict the
active-subscription query to order-backed subscriptions, excluding subscriptions
with Source "admin" while preserving the existing renewal conditions. Extend
TestHasRenewingUserSubscription with an admin-granted subscription case that
verifies it does not block checkout.
In `@model/task_cas_test.go`:
- Around line 49-51: The test cleanup list used by truncateTables must include
the migrated user_usage_counters and user_image_uploads tables. Update
truncateTables to delete both UserUsageCounter and UserImageUpload records,
preserving explicit fixture initialization so test state cannot leak between
tests.
In `@model/usage_counter.go`:
- Around line 100-121: Ensure the MySQL SQL_DSN does not include
clientFoundRows=true, preserving RowsAffected semantics so the fresh count from
unique-hash insertion reflects only newly inserted rows. Keep the existing
Create call and conditional images_used update unchanged.
In `@service/billing_session.go`:
- Around line 193-201: Add rollback for images reserved by ReserveImages when
either PreConsumeTokenQuota or s.funding.PreConsume fails after the reservation
commits. Update the relevant failure paths in the billing session flow to
release the reservation before returning the error, while preserving existing
error responses and successful consumption behavior.
In `@service/task_billing_test.go`:
- Around line 71-72: Update the test database cleanup near the existing
user_usage_counters and user_image_uploads deletions to also delete rows from
the AirwallexBillingCustomer table, using its actual table name
airwallex_billing_customers. Ensure billing-customer mappings are cleared
between tests.
In `@service/usage_cost_test.go`:
- Around line 31-42: Add a regression assertion to
TestShadowCostClampsNegativeAndOversizedCachedCounts for a negative prompt
count, then update ShadowCost to clamp promptTokens to zero before calculating
fresh, preserving the existing cachedTokens and completionTokens clamping
behavior.
In `@service/usage_cost.go`:
- Around line 11-29: Update ShadowCost to clamp promptTokens to zero before
reconciling cachedTokens, preventing negative fresh or cached token amounts and
ensuring the returned charge cannot be negative. Validate modelRatio,
completionRatio, and cacheRatio with math.IsNaN/math.IsInf, returning zero when
any ratio is non-finite before calling decimal.NewFromFloat; add the math
import.
In `@service/usage_images_test.go`:
- Around line 21-32: Update imageMessageWithPointerType and its test usage to
construct the distinct alternate image-content representation produced by
dto.Message.ParseContent, rather than another pointer-based dto.MediaContent
shape. Preserve imageMessage for the existing representation and ensure the test
at lines 83-90 exercises cross-representation deduplication; remove the helper
only if that distinct representation cannot be constructed.
In `@setting/usage_limit_test.go`:
- Around line 9-57: Update the monthly-limit tests around
UpdateMonthlyCostLimitGroupByJSONString and
UpdateMonthlyImageLimitGroupByJSONString to capture each setting’s existing JSON
configuration before mutation, then restore that exact value in t.Cleanup
instead of resetting to "{}". Initialize the relevant settings explicitly in
each test fixture while preserving the existing assertions.
In `@setting/usage_limit.go`:
- Around line 30-35: Update setting/usage_limit.go:30-35 in
UpdateMonthlyCostLimitGroupByJSONString and setting/usage_limit.go:66-71 in the
corresponding image-limit update function to decode into temporary maps, run the
appropriate non-negative validation helper, and replace MonthlyCostLimitGroup or
MonthlyImageLimitGroup only after decoding and validation succeed; preserve the
existing live map when either step fails.
---
Nitpick comments:
In `@model/usage_counter.go`:
- Around line 34-40: Add a retention job for UserImageUpload that deletes
records whose cycle_start is older than the current cycle, and integrate it with
the existing scheduled cleanup mechanism. Ensure the cleanup covers both table
rows and the associated unique index while preserving current-cycle records.
In `@service/subscription_pause.go`:
- Around line 41-46: Replace the string-based check in
causeIsMissingSubscription with errors.Is against an exported model-package
sentinel named ErrNoActiveSubscription. Define and return or wrap this sentinel
where the model reports the missing subscription, preserving detection through
wrapped errors and removing the direct error-text dependency.
In `@service/text_quota.go`:
- Around line 312-330: Move shadow usage accrual in PostTextConsumeQuota to an
asynchronous gopool.Go callback, matching the existing
perfmetrics.RecordRelaySample pattern, so settlement does not wait for
CycleSubscriptionFor or model.AddUsage. Before launching the goroutine, copy all
required scalar values from relayInfo and usage; have the callback perform the
existing accrueShadowUsage logic without accessing request-owned relayInfo
afterward. Add or reuse a synchronization seam so usage_shadow_accrual_test.go
can wait for completion before asserting the counter.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 1f26e7cd-90c9-42a2-9faf-90c12d230744
📒 Files selected for processing (39)
controller/option.gocontroller/subscription_payment_airwallex.gocontroller/usage.gocontroller/usage_test.goi18n/keys.goi18n/locales/en.yamli18n/locales/zh-CN.yamli18n/locales/zh-TW.yamlmiddleware/model-rate-limit.gomiddleware/model-rate-limit_test.gomodel/main.gomodel/option.gomodel/subscription.gomodel/subscription_cancel_test.gomodel/task_cas_test.gomodel/usage_counter.gomodel/usage_counter_test.gorouter/api-router.goservice/billing_session.goservice/subscription_pause.goservice/subscription_pause_test.goservice/subscription_reconcile.goservice/subscription_reconcile_test.goservice/subscription_reset_task.goservice/task_billing_test.goservice/text_quota.goservice/usage.goservice/usage_cost.goservice/usage_cost_test.goservice/usage_cycle.goservice/usage_cycle_test.goservice/usage_gate.goservice/usage_gate_test.goservice/usage_images.goservice/usage_images_test.goservice/usage_shadow_accrual_test.goservice/usage_test.gosetting/usage_limit.gosetting/usage_limit_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
- model/main.go
- model/option.go
- router/api-router.go
| // Nothing in this path supersedes an existing subscription, so a second | ||
| // checkout produces a second live Airwallex subscription and BOTH bill. | ||
| // Refuse here rather than in the portal: the button is UX, this is the | ||
| // thing that actually protects the customer's card. | ||
| renewing, err := model.HasRenewingUserSubscription(userId) | ||
| if err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| if renewing { | ||
| common.ApiErrorMsg(c, "你已有生效中的订阅。如需更换套餐,请联系我们。") | ||
| return | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Reserve checkout creation before calling Airwallex.
HasRenewingUserSubscription only sees completed local subscriptions. Two requests can both pass this check before either hosted checkout is paid. Each request can then create a separate Airwallex subscription checkout.
Create and atomically claim one pending Airwallex order before genAirwallexSubscriptionLink. Reject or reuse an unexpired pending order. Release or expire the reservation when link creation fails.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@controller/subscription_payment_airwallex.go` around lines 147 - 159, Add an
atomic pending Airwallex order reservation immediately before
genAirwallexSubscriptionLink, so concurrent checkout requests cannot both
proceed after HasRenewingUserSubscription. Reject or reuse an unexpired
reservation for the user, and release or expire the reservation if link creation
fails; preserve the existing completed-subscription checks and error responses.
| func percentUsed(used, limit int64) int { | ||
| if limit <= 0 || used <= 0 { | ||
| return 0 | ||
| } | ||
| pct := int((used*100 + limit/2) / limit) | ||
| if pct > 100 { | ||
| return 100 | ||
| } | ||
| return pct |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Use overflow-safe percentage arithmetic.
used*100 can overflow before division. For large valid counters, this can return a negative or incorrect percent instead of the required capped value.
Calculate the rounded fraction with overflow-safe arithmetic. Add a regression case near math.MaxInt64.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@controller/usage.go` around lines 111 - 119, Update percentUsed to avoid
multiplying used by 100 before division, using overflow-safe arithmetic that
preserves nearest-integer rounding and caps valid results at 100. Add a
regression test covering counters near math.MaxInt64 and verify the result
remains correct.
| // rateLimitWindowStart converts a stored rate-limit entry into a unix | ||
| // timestamp. recordRedisRequest stamps entries with a local clock under a | ||
| // literal "Z", so an entry is only meaningful compared against a now formatted | ||
| // the same way — the same trick checkRedisRateLimit uses. | ||
| func rateLimitWindowStart(entry string, now time.Time) (int64, bool) { | ||
| oldest, err := time.Parse(timeFormat, entry) | ||
| if err != nil { | ||
| return 0, false | ||
| } | ||
| nowInSameFrame, err := time.Parse(timeFormat, now.Format(timeFormat)) | ||
| if err != nil { | ||
| return 0, false | ||
| } | ||
| age := nowInSameFrame.Sub(oldest) | ||
| if age < 0 { | ||
| age = 0 | ||
| } | ||
| return now.Add(-age).Unix(), true |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n -C 8 'timeFormat|recordRedisRequest|rateLimitWindowStart|time\.Now\(\)\.Format' \
middleware/model-rate-limit.go middleware/model-rate-limit_test.goRepository: QuantumNous/new-api
Length of output: 11190
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- time format and imports ---'
sed -n '1,85p' middleware/model-rate-limit.go
printf '%s\n' '--- callers and time-zone configuration ---'
rg -n -C 4 'countRateLimitWindow|rateLimitWindowStart|time\.Local|TZ=|time/tzdata|LoadLocation|ModelRequestRateLimit' --glob '!vendor/**' .
printf '%s\n' '--- timezone-sensitive behavior probe ---'
cat >/tmp/rate_limit_dst_probe.go <<'EOF'
package main
import (
"fmt"
"os"
"time"
)
const timeFormat = "2006-01-02T15:04:05Z"
func rateLimitWindowStart(entry string, now time.Time) (int64, bool) {
oldest, err := time.Parse(timeFormat, entry)
if err != nil {
return 0, false
}
nowInSameFrame, err := time.Parse(timeFormat, now.Format(timeFormat))
if err != nil {
return 0, false
}
age := nowInSameFrame.Sub(oldest)
if age < 0 {
age = 0
}
return now.Add(-age).Unix(), true
}
func main() {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
panic(err)
}
// The repeated 01:30 local hour during the 2025 fall-back transition.
first := time.Date(2025, 11, 2, 1, 30, 0, 0, loc)
second := first.Add(time.Hour)
entry := first.Format(timeFormat)
now := second
start, ok := rateLimitWindowStart(entry, now)
fmt.Printf("TZ=%q local=%s entry=%q now=%s actual_age=%s start=%s ok=%v\n",
os.Getenv("TZ"), first, entry, now, now.Sub(first), time.Unix(start, 0).In(loc), ok)
}
EOF
TZ=America/New_York go run /tmp/rate_limit_dst_probe.goRepository: QuantumNous/new-api
Length of output: 50377
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- timeFormat definition ---'
rg -n -C 2 'timeFormat\s*=' middleware --glob '*.go'
printf '%s\n' '--- image and runtime timezone defaults ---'
rg -n -C 3 'ENV[[:space:]]+TZ|TZ=|time\.Local|zoneinfo' Dockerfile* docker-compose*.yml .github docs README* 2>/dev/null | head -200 || true
printf '%s\n' '--- standalone DST probe ---'
cat >/tmp/rate_limit_dst_probe.go <<'EOF'
package main
import (
"fmt"
"time"
)
const timeFormat = "2006-01-02T15:04:05Z"
func rateLimitWindowStart(entry string, now time.Time) (int64, bool) {
oldest, err := time.Parse(timeFormat, entry)
if err != nil {
return 0, false
}
nowInSameFrame, err := time.Parse(timeFormat, now.Format(timeFormat))
if err != nil {
return 0, false
}
age := nowInSameFrame.Sub(oldest)
if age < 0 {
age = 0
}
return now.Add(-age).Unix(), true
}
func main() {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
panic(err)
}
first := time.Date(2025, 11, 2, 1, 30, 0, 0, loc)
second := first.Add(time.Hour)
entry := first.Format(timeFormat)
start, ok := rateLimitWindowStart(entry, second)
fmt.Printf("first=%s\nsecond=%s\nentry=%q\nactual_age=%s\nreported_start=%s\nok=%v\n",
first, second, entry, second.Sub(first), time.Unix(start, 0).In(loc), ok)
}
EOF
go run /tmp/rate_limit_dst_probe.goRepository: QuantumNous/new-api
Length of output: 8818
Store rate-limit entries with unambiguous timestamps.
When time.Local uses daylight-saving time, the repeated fall-back hour can make an entry appear newer than now. The negative-age clamp then reports now as the window start. Update both readers and retain compatibility with existing entries.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@middleware/model-rate-limit.go` around lines 202 - 219, Update
recordRedisRequest to store unambiguous UTC timestamps, and update both
rateLimitWindowStart and checkRedisRateLimit to read the new format while
retaining parsing support for existing local-clock entries. Ensure DST fall-back
entries are ordered correctly and do not rely on the negative-age clamp to
determine the window start.
| func TestMarkUserSubscriptionsCancelled(t *testing.T) { | ||
| truncateTables(t) | ||
| now := common.GetTimestamp() | ||
|
|
||
| live := &UserSubscription{UserId: 7, PlanId: 1, Status: "active", | ||
| StartTime: now - 100, EndTime: now + 86400, UpgradeGroup: "plus", Source: "order"} | ||
| if err := DB.Create(live).Error; err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| // Already expired: cancelling auto-renewal says nothing about a term that | ||
| // has already ended, and rewriting history here would confuse support. | ||
| old := &UserSubscription{UserId: 7, PlanId: 1, Status: "expired", | ||
| StartTime: now - 200, EndTime: now - 100, UpgradeGroup: "plus", Source: "order"} | ||
| if err := DB.Create(old).Error; err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| other := &UserSubscription{UserId: 8, PlanId: 1, Status: "active", | ||
| StartTime: now - 100, EndTime: now + 86400, UpgradeGroup: "plus", Source: "order"} | ||
| if err := DB.Create(other).Error; err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| n, err := MarkUserSubscriptionsCancelled(7, now) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if n != 1 { | ||
| t.Fatalf("expected 1 row marked, got %d", n) | ||
| } | ||
|
|
||
| var got UserSubscription | ||
| if err := DB.First(&got, live.Id).Error; err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if got.CancelledAt != now { | ||
| t.Fatalf("cancelled_at = %d, want %d", got.CancelledAt, now) | ||
| } | ||
| // Access must survive cancellation: the customer paid through EndTime. | ||
| if got.Status != "active" { | ||
| t.Fatalf("status = %q, want it left active — cancelling must not revoke the paid-for remainder", got.Status) | ||
| } | ||
| if got.EndTime != live.EndTime { | ||
| t.Fatalf("end_time moved from %d to %d", live.EndTime, got.EndTime) | ||
| } | ||
|
|
||
| var untouchedExpired, untouchedOther UserSubscription | ||
| if err := DB.First(&untouchedExpired, old.Id).Error; err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if untouchedExpired.CancelledAt != 0 { | ||
| t.Fatal("an already-expired row must not be marked") | ||
| } | ||
| if err := DB.First(&untouchedOther, other.Id).Error; err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if untouchedOther.CancelledAt != 0 { | ||
| t.Fatal("another user's row must not be marked") | ||
| } | ||
| } | ||
|
|
||
| func TestMarkUserSubscriptionsCancelledIsIdempotent(t *testing.T) { | ||
| truncateTables(t) | ||
| now := common.GetTimestamp() | ||
|
|
||
| sub := &UserSubscription{UserId: 7, PlanId: 1, Status: "active", | ||
| StartTime: now - 100, EndTime: now + 86400, UpgradeGroup: "plus", Source: "order"} | ||
| if err := DB.Create(sub).Error; err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| if _, err := MarkUserSubscriptionsCancelled(7, now); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| // A duplicate webhook, an endpoint retry, and the reconcile pass all land | ||
| // on the same row. The second write must be a no-op that preserves the | ||
| // original cancellation time rather than sliding it forward. | ||
| n, err := MarkUserSubscriptionsCancelled(7, now+500) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if n != 0 { | ||
| t.Fatalf("second mark affected %d rows, want 0", n) | ||
| } | ||
|
|
||
| var got UserSubscription | ||
| if err := DB.First(&got, sub.Id).Error; err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if got.CancelledAt != now { | ||
| t.Fatalf("cancelled_at = %d, want the first value %d", got.CancelledAt, now) | ||
| } | ||
| } | ||
|
|
||
| func TestListRenewingUserSubscriptions(t *testing.T) { | ||
| truncateTables(t) | ||
| now := common.GetTimestamp() | ||
|
|
||
| renewing := &UserSubscription{UserId: 7, PlanId: 1, Status: "active", | ||
| StartTime: now - 100, EndTime: now + 86400, UpgradeGroup: "plus", Source: "order"} | ||
| cancelled := &UserSubscription{UserId: 8, PlanId: 1, Status: "active", | ||
| StartTime: now - 100, EndTime: now + 86400, UpgradeGroup: "plus", Source: "order", CancelledAt: now} | ||
| expired := &UserSubscription{UserId: 9, PlanId: 1, Status: "expired", | ||
| StartTime: now - 200, EndTime: now - 100, UpgradeGroup: "plus", Source: "order"} | ||
| // A comped account has no Airwallex subscription to find, so it looks | ||
| // exactly like a cancelled one to the reconcile. It must never be a | ||
| // candidate. | ||
| granted := &UserSubscription{UserId: 10, PlanId: 1, Status: "active", | ||
| StartTime: now - 100, EndTime: now + 86400, UpgradeGroup: "pro", Source: "admin"} | ||
| for _, s := range []*UserSubscription{renewing, cancelled, expired, granted} { | ||
| if err := DB.Create(s).Error; err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| } | ||
|
|
||
| got, err := ListRenewingUserSubscriptions(100) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if len(got) != 1 { | ||
| t.Fatalf("got %d candidates, want 1", len(got)) | ||
| } | ||
| if got[0].UserId != 7 { | ||
| t.Fatalf("candidate user = %d, want 7", got[0].UserId) | ||
| } | ||
| } | ||
|
|
||
| func TestGetUserIdByAirwallexBillingCustomerId(t *testing.T) { | ||
| truncateTables(t) | ||
| if err := SaveAirwallexBillingCustomerId(42, "bcus_test_1"); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if got := GetUserIdByAirwallexBillingCustomerId("bcus_test_1"); got != 42 { | ||
| t.Fatalf("got user %d, want 42", got) | ||
| } | ||
| if got := GetUserIdByAirwallexBillingCustomerId("bcus_unknown"); got != 0 { | ||
| t.Fatalf("unknown customer returned user %d, want 0", got) | ||
| } | ||
| if got := GetUserIdByAirwallexBillingCustomerId(""); got != 0 { | ||
| t.Fatalf("empty customer returned user %d, want 0", got) | ||
| } | ||
| } | ||
|
|
||
| func TestHasRenewingUserSubscription(t *testing.T) { | ||
| truncateTables(t) | ||
| now := common.GetTimestamp() | ||
|
|
||
| mk := func(userId int, over func(*UserSubscription)) { | ||
| s := &UserSubscription{UserId: userId, PlanId: 1, Status: "active", Source: "order", | ||
| StartTime: now - 100, EndTime: now + 86400, UpgradeGroup: "plus"} | ||
| if over != nil { | ||
| over(s) | ||
| } | ||
| if err := DB.Create(s).Error; err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| } | ||
|
|
||
| mk(7, nil) // renewing | ||
| mk(8, func(s *UserSubscription) { s.CancelledAt = now }) // cancelled, still running | ||
| mk(9, func(s *UserSubscription) { s.Status = "expired"; s.EndTime = now - 1 }) // lapsed | ||
|
|
||
| for _, tc := range []struct { | ||
| userId int | ||
| want bool | ||
| why string | ||
| }{ | ||
| {7, true, "a renewing subscription must block a second checkout — both would bill"}, | ||
| {8, false, "cancelled means it will never bill again, so resubscribing is safe and must stay open"}, | ||
| {9, false, "a lapsed subscription must not block a new purchase"}, | ||
| {99, false, "a user with no subscription must be able to buy"}, | ||
| } { | ||
| got, err := HasRenewingUserSubscription(tc.userId) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if got != tc.want { | ||
| t.Errorf("user %d: got %v want %v — %s", tc.userId, got, tc.want, tc.why) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Use testify assertions in the new backend tests.
These new tests use direct t.Fatal, t.Fatalf, and t.Errorf assertions. Convert setup and fatal assertions to require, and non-fatal assertions to assert.
model/subscription_cancel_test.go#L9-L188: replace direct test assertions withrequireandassert.service/subscription_reconcile_test.go#L52-L149: replace direct test assertions withrequireandassert.
As per coding guidelines, “New or substantially rewritten tests must use testify/require for setup and fatal assertions and testify/assert for non-fatal checks.”
📍 Affects 2 files
model/subscription_cancel_test.go#L9-L188(this comment)service/subscription_reconcile_test.go#L52-L149
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@model/subscription_cancel_test.go` around lines 9 - 188, Replace direct
t.Fatal/t.Fatalf setup and fatal assertions with testify/require, and replace
non-fatal t.Errorf checks with testify/assert throughout
model/subscription_cancel_test.go lines 9-188 and
service/subscription_reconcile_test.go lines 52-149. Add or reuse the testify
assertion imports while preserving each test’s existing expectations and control
flow.
Source: Coding guidelines
| res := DB.Model(&UserSubscription{}). | ||
| Where("user_id = ? AND status = ? AND cancelled_at = ?", userId, "active", 0). | ||
| Updates(map[string]any{"cancelled_at": at, "updated_at": common.GetTimestamp()}) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Track cancellation by Airwallex subscription ID, not by user ID.
A cancellation for an old subscription currently marks every active local subscription for the user as non-renewing. A delayed webhook can therefore cancel a newer replacement subscription. The reconciliation pass has the same problem.
model/subscription.go#L875-L877: persist an Airwallex subscription ID on the local subscription and update only the row mapped to that ID.controller/subscription_payment_airwallex.go#L296-L324: pass each cancelled Airwallexsub.Idto the local update.controller/subscription_payment_airwallex.go#L568-L582: use the webhook subscription ID after the fallback fetch.service/subscription_reconcile.go#L61-L80: compare mapped local subscription IDs with the active Airwallex IDs before marking rows cancelled.model/subscription_cancel_test.go#L9-L188: add a regression test where an old cancellation arrives after a replacement subscription starts.
📍 Affects 4 files
model/subscription.go#L875-L877(this comment)controller/subscription_payment_airwallex.go#L296-L324controller/subscription_payment_airwallex.go#L568-L582service/subscription_reconcile.go#L61-L80model/subscription_cancel_test.go#L9-L188
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@model/subscription.go` around lines 875 - 877, Track cancellations by
Airwallex subscription ID instead of user ID: in model/subscription.go:875-877,
persist the provider ID and update only its mapped local row; in
controller/subscription_payment_airwallex.go:296-324 and :568-582, pass each
cancelled sub.Id, including the webhook ID after fallback fetching; in
service/subscription_reconcile.go:61-80, compare mapped local IDs against active
Airwallex IDs before cancelling; in model/subscription_cancel_test.go:9-188, add
coverage for an old cancellation arriving after a replacement subscription
starts.
| func TestShadowCostClampsNegativeAndOversizedCachedCounts(t *testing.T) { | ||
| require.Equal(t, int64(400), ShadowCost(1000, 0, -50, 0.4, 4.0, 0.2)) | ||
| // cached cannot exceed prompt; treat the excess as fully cached | ||
| require.Equal(t, int64(80), ShadowCost(1000, 0, 5000, 0.4, 4.0, 0.2)) | ||
| } | ||
|
|
||
| // A broken upstream can hand back a negative completion count; treated | ||
| // literally that would *decrement* the cumulative shadow-cost counter, so it | ||
| // is clamped to 0 the same way cachedTokens already is. | ||
| func TestShadowCostClampsNegativeCompletionTokens(t *testing.T) { | ||
| require.Equal(t, int64(400), ShadowCost(1000, -50, 0, 0.4, 4.0, 0.2)) | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Clamp negative prompt token counts.
ShadowCost clamps cachedTokens and completionTokens, but it does not clamp promptTokens. A negative prompt count can create a negative shadow cost and decrement cumulative usage.
Add a regression case for a negative prompt count. Clamp promptTokens to zero before computing fresh.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@service/usage_cost_test.go` around lines 31 - 42, Add a regression assertion
to TestShadowCostClampsNegativeAndOversizedCachedCounts for a negative prompt
count, then update ShadowCost to clamp promptTokens to zero before calculating
fresh, preserving the existing cachedTokens and completionTokens clamping
behavior.
| func ShadowCost(promptTokens, completionTokens, cachedTokens int, modelRatio, completionRatio, cacheRatio float64) int64 { | ||
| if modelRatio <= 0 { | ||
| return 0 | ||
| } | ||
| if cachedTokens < 0 { | ||
| cachedTokens = 0 | ||
| } | ||
| if completionTokens < 0 { | ||
| completionTokens = 0 | ||
| } | ||
| if cachedTokens > promptTokens { | ||
| cachedTokens = promptTokens | ||
| } | ||
| fresh := decimal.NewFromInt(int64(promptTokens - cachedTokens)) | ||
| cached := decimal.NewFromInt(int64(cachedTokens)).Mul(decimal.NewFromFloat(cacheRatio)) | ||
| output := decimal.NewFromInt(int64(completionTokens)).Mul(decimal.NewFromFloat(completionRatio)) | ||
|
|
||
| weighted := fresh.Add(cached).Add(output) | ||
| return weighted.Mul(decimal.NewFromFloat(modelRatio)).Round(0).IntPart() |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Clamp promptTokens and reject non-finite ratios.
Two unvalidated inputs reach the arithmetic.
A negative promptTokens is not clamped. The cachedTokens > promptTokens branch then sets cachedTokens to that negative value, fresh becomes 0, and cached becomes negative. ShadowCost returns a negative value, and model.AddUsage decrements cost_used, which lowers the recorded usage below the truth.
decimal.NewFromFloat panics on NaN and Inf. modelRatio <= 0 is false for NaN, so a NaN ratio reaches decimal.NewFromFloat and panics inside the settlement path.
🛡️ Proposed fix
func ShadowCost(promptTokens, completionTokens, cachedTokens int, modelRatio, completionRatio, cacheRatio float64) int64 {
- if modelRatio <= 0 {
+ if !(modelRatio > 0) || math.IsInf(modelRatio, 0) {
return 0
}
+ if math.IsNaN(completionRatio) || math.IsInf(completionRatio, 0) {
+ completionRatio = 0
+ }
+ if math.IsNaN(cacheRatio) || math.IsInf(cacheRatio, 0) {
+ cacheRatio = 0
+ }
+ if promptTokens < 0 {
+ promptTokens = 0
+ }
if cachedTokens < 0 {
cachedTokens = 0
}Add "math" to the imports.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func ShadowCost(promptTokens, completionTokens, cachedTokens int, modelRatio, completionRatio, cacheRatio float64) int64 { | |
| if modelRatio <= 0 { | |
| return 0 | |
| } | |
| if cachedTokens < 0 { | |
| cachedTokens = 0 | |
| } | |
| if completionTokens < 0 { | |
| completionTokens = 0 | |
| } | |
| if cachedTokens > promptTokens { | |
| cachedTokens = promptTokens | |
| } | |
| fresh := decimal.NewFromInt(int64(promptTokens - cachedTokens)) | |
| cached := decimal.NewFromInt(int64(cachedTokens)).Mul(decimal.NewFromFloat(cacheRatio)) | |
| output := decimal.NewFromInt(int64(completionTokens)).Mul(decimal.NewFromFloat(completionRatio)) | |
| weighted := fresh.Add(cached).Add(output) | |
| return weighted.Mul(decimal.NewFromFloat(modelRatio)).Round(0).IntPart() | |
| func ShadowCost(promptTokens, completionTokens, cachedTokens int, modelRatio, completionRatio, cacheRatio float64) int64 { | |
| if !(modelRatio > 0) || math.IsInf(modelRatio, 0) { | |
| return 0 | |
| } | |
| if math.IsNaN(completionRatio) || math.IsInf(completionRatio, 0) { | |
| completionRatio = 0 | |
| } | |
| if math.IsNaN(cacheRatio) || math.IsInf(cacheRatio, 0) { | |
| cacheRatio = 0 | |
| } | |
| if promptTokens < 0 { | |
| promptTokens = 0 | |
| } | |
| if cachedTokens < 0 { | |
| cachedTokens = 0 | |
| } | |
| if completionTokens < 0 { | |
| completionTokens = 0 | |
| } | |
| if cachedTokens > promptTokens { | |
| cachedTokens = promptTokens | |
| } | |
| fresh := decimal.NewFromInt(int64(promptTokens - cachedTokens)) | |
| cached := decimal.NewFromInt(int64(cachedTokens)).Mul(decimal.NewFromFloat(cacheRatio)) | |
| output := decimal.NewFromInt(int64(completionTokens)).Mul(decimal.NewFromFloat(completionRatio)) | |
| weighted := fresh.Add(cached).Add(output) | |
| return weighted.Mul(decimal.NewFromFloat(modelRatio)).Round(0).IntPart() | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@service/usage_cost.go` around lines 11 - 29, Update ShadowCost to clamp
promptTokens to zero before reconciling cachedTokens, preventing negative fresh
or cached token amounts and ensuring the returned charge cannot be negative.
Validate modelRatio, completionRatio, and cacheRatio with math.IsNaN/math.IsInf,
returning zero when any ratio is non-finite before calling decimal.NewFromFloat;
add the math import.
Source: Coding guidelines
| // imageMessageWithPointerType constructs a message with pointer-type ImageUrl, | ||
| // which is what ParseContent() produces from inbound JSON requests. | ||
| func imageMessageWithPointerType(urls ...string) dto.Message { | ||
| parts := make([]dto.MediaContent, 0, len(urls)) | ||
| for _, u := range urls { | ||
| url := u // capture loop variable | ||
| parts = append(parts, dto.MediaContent{Type: dto.ContentTypeImageURL, ImageUrl: &dto.MessageImageUrl{Url: url}}) | ||
| } | ||
| m := dto.Message{Role: "user"} | ||
| m.SetMediaContent(parts) | ||
| return m | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Test a distinct image-content representation.
imageMessageWithPointerType constructs the same dto.MediaContent shape as imageMessage. The test at lines 83-90 therefore repeats pointer coverage and does not verify cross-representation deduplication.
Construct the alternate representation supported by dto.Message.ParseContent, or remove the duplicate helper and claim.
As per coding guidelines, backend tests must protect API contracts and regression paths.
Also applies to: 83-90
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@service/usage_images_test.go` around lines 21 - 32, Update
imageMessageWithPointerType and its test usage to construct the distinct
alternate image-content representation produced by dto.Message.ParseContent,
rather than another pointer-based dto.MediaContent shape. Preserve imageMessage
for the existing representation and ensure the test at lines 83-90 exercises
cross-representation deduplication; remove the helper only if that distinct
representation cannot be constructed.
Source: Coding guidelines
| func TestMonthlyLimitsRoundTripThroughJSON(t *testing.T) { | ||
| require.NoError(t, UpdateMonthlyCostLimitGroupByJSONString(`{"free":1300000,"plus":13200000,"pro":0}`)) | ||
| t.Cleanup(func() { _ = UpdateMonthlyCostLimitGroupByJSONString(`{}`) }) | ||
|
|
||
| require.Equal(t, int64(1300000), GetMonthlyCostLimit("free")) | ||
| require.Equal(t, int64(13200000), GetMonthlyCostLimit("plus")) | ||
| require.Equal(t, int64(0), GetMonthlyCostLimit("pro"), "0 means uncapped; Pro is bounded by its pool") | ||
| } | ||
|
|
||
| // An unknown group must not inherit someone else's ceiling. | ||
| func TestMonthlyCostLimitForAnUnknownGroupIsUncapped(t *testing.T) { | ||
| require.NoError(t, UpdateMonthlyCostLimitGroupByJSONString(`{"free":1300000}`)) | ||
| t.Cleanup(func() { _ = UpdateMonthlyCostLimitGroupByJSONString(`{}`) }) | ||
|
|
||
| require.Equal(t, int64(0), GetMonthlyCostLimit("default")) | ||
| require.Equal(t, int64(0), GetMonthlyCostLimit("")) | ||
| } | ||
|
|
||
| func TestMonthlyImageLimits(t *testing.T) { | ||
| require.NoError(t, UpdateMonthlyImageLimitGroupByJSONString(`{"free":0,"plus":0,"pro":100}`)) | ||
| t.Cleanup(func() { _ = UpdateMonthlyImageLimitGroupByJSONString(`{}`) }) | ||
|
|
||
| limit, found := GetMonthlyImageLimit("pro") | ||
| require.True(t, found) | ||
| require.Equal(t, 100, limit) | ||
|
|
||
| limit, found = GetMonthlyImageLimit("plus") | ||
| require.True(t, found, "plus is explicitly configured at 0: no images allowed") | ||
| require.Equal(t, 0, limit) | ||
| } | ||
|
|
||
| // A group absent from the map has no configured entitlement at all — distinct | ||
| // from a group explicitly configured at 0. The caller must be able to tell | ||
| // these apart, or an unconfigured group gets refused like a zero-entitlement one. | ||
| func TestMonthlyImageLimitForAnUnconfiguredGroupIsNotFound(t *testing.T) { | ||
| require.NoError(t, UpdateMonthlyImageLimitGroupByJSONString(`{"pro":100}`)) | ||
| t.Cleanup(func() { _ = UpdateMonthlyImageLimitGroupByJSONString(`{}`) }) | ||
|
|
||
| limit, found := GetMonthlyImageLimit("free") | ||
| require.False(t, found) | ||
| require.Equal(t, 0, limit) | ||
| } | ||
|
|
||
| func TestMonthlyLimitValidationRejectsNegativesAndGarbage(t *testing.T) { | ||
| require.Error(t, CheckMonthlyCostLimitGroup(`{"free":-1}`)) | ||
| require.Error(t, CheckMonthlyCostLimitGroup(`not json`)) | ||
| require.Error(t, CheckMonthlyImageLimitGroup(`{"pro":-5}`)) | ||
| require.NoError(t, CheckMonthlyCostLimitGroup(`{"free":0}`)) | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Restore the previous monthly-limit configuration.
Each cleanup replaces shared settings with {}. This can change the configuration observed by later tests.
Capture each prior JSON value before mutation. Restore that value in t.Cleanup.
As per coding guidelines, initialize settings explicitly in test fixtures.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@setting/usage_limit_test.go` around lines 9 - 57, Update the monthly-limit
tests around UpdateMonthlyCostLimitGroupByJSONString and
UpdateMonthlyImageLimitGroupByJSONString to capture each setting’s existing JSON
configuration before mutation, then restore that exact value in t.Cleanup
instead of resetting to "{}". Initialize the relevant settings explicitly in
each test fixture while preserving the existing assertions.
Source: Coding guidelines
| func UpdateMonthlyCostLimitGroupByJSONString(jsonStr string) error { | ||
| monthlyLimitMu.Lock() | ||
| defer monthlyLimitMu.Unlock() | ||
| MonthlyCostLimitGroup = make(map[string]int64) | ||
| return common.Unmarshal([]byte(jsonStr), &MonthlyCostLimitGroup) | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Commit monthly-limit maps only after validation succeeds.
Both update functions clear the live map before common.Unmarshal completes. Invalid or partially decoded JSON can erase valid running configuration. Both functions also bypass their corresponding non-negative validation helper.
setting/usage_limit.go#L30-L35: Decode and validate into a temporary cost-limit map. ReplaceMonthlyCostLimitGrouponly after success.setting/usage_limit.go#L66-L71: Decode and validate into a temporary image-limit map. ReplaceMonthlyImageLimitGrouponly after success.
Proposed update pattern
func UpdateMonthlyCostLimitGroupByJSONString(jsonStr string) error {
+ next := make(map[string]int64)
+ if err := common.Unmarshal([]byte(jsonStr), &next); err != nil {
+ return err
+ }
+ for group, limit := range next {
+ if limit < 0 {
+ return errors.New("monthly cost limit must be >= 0 for group " + group)
+ }
+ }
monthlyLimitMu.Lock()
defer monthlyLimitMu.Unlock()
- MonthlyCostLimitGroup = make(map[string]int64)
- return common.Unmarshal([]byte(jsonStr), &MonthlyCostLimitGroup)
+ MonthlyCostLimitGroup = next
+ return nil
}📍 Affects 1 file
setting/usage_limit.go#L30-L35(this comment)setting/usage_limit.go#L66-L71
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@setting/usage_limit.go` around lines 30 - 35, Update
setting/usage_limit.go:30-35 in UpdateMonthlyCostLimitGroupByJSONString and
setting/usage_limit.go:66-71 in the corresponding image-limit update function to
decode into temporary maps, run the appropriate non-negative validation helper,
and replace MonthlyCostLimitGroup or MonthlyImageLimitGroup only after decoding
and validation succeed; preserve the existing live map when either step fails.
51fdfc5 to
2b6f1df
Compare
Important
📝 变更描述 / Description
(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)
🚀 变更类型 / Type of change
🔗 关联任务 / Related Issue
✅ 提交前检查项 / Checklist
Bug fix,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。📸 运行证明 / Proof of Work
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
Summary by CodeRabbit