diff --git a/.github/audit-exceptions.yml b/.github/audit-exceptions.yml
index b71422a78a..4e05aae66b 100644
--- a/.github/audit-exceptions.yml
+++ b/.github/audit-exceptions.yml
@@ -28,3 +28,10 @@ exceptions:
mitigation: "No user-controlled template strings; plan to migrate to native JS alternatives"
expires_on: "2026-07-02"
owner: "security@your-domain"
+ - package: axios
+ advisory: "GHSA-3p68-rc4w-qgx5"
+ severity: critical
+ reason: "NO_PROXY bypass not exploitable; all API calls go to known endpoints via server-side proxy"
+ mitigation: "Proxy configuration not user-controlled; upgrade when axios releases fix"
+ expires_on: "2026-07-10"
+ owner: "security@your-domain"
diff --git a/README.md b/README.md
index b64a7647c5..c2715eae0c 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,16 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
Thanks to YLS Code for sponsoring this project! YLS Code is dedicated to building secure enterprise-grade Coding Agent productivity services, offering stable and fast Codex / Claude / Gemini subscription services along with pay-as-you-go API options for flexible choices. Register now for a limited-time 3-day Codex trial bonus!
+
+
+Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via this link to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!
+
+
+
+
+Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via this link , you'll receive an extra 10% bonus credit on your first top-up!
+
+
## Ecosystem
diff --git a/README_CN.md b/README_CN.md
index 5b6be04ccd..0ace1f77a6 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -76,10 +76,20 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的
-
+
感谢 伊莉思Code 赞助了本项目! 伊莉思Code 致力于构建安全的企业级Coding Agent生产力服务,提供稳定快速的 Codex / Claude / Gemini 订阅服务与即用即付API多种方案灵活选择,限时注册赠送 3 天 Codex 试用福利!
+
+
+感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过此链接 注册,首次充值立享 8 折优惠,企业客户最高可享 75 折!
+
+
+
+
+感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过此链接 注册,首次充值可额外获得 10% 赠送额度!
+
+
## 生态项目
diff --git a/README_JA.md b/README_JA.md
index 4e7ae76532..d74ca9cebf 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -80,6 +80,16 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
YLS Code のご支援に感謝します!YLS Code は安全なエンタープライズグレードの Coding Agent 生産性サービスの構築に取り組んでおり、安定かつ高速な Codex / Claude / Gemini サブスクリプションサービスと従量課金 API の柔軟なプランを提供しています。期間限定で新規登録者に 3 日間の Codex 試用特典をプレゼント中!
+
+
+AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:こちらのリンク から登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ!
+
+
+
+
+AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:こちらのリンク から登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント!
+
+
## エコシステム
diff --git a/assets/partners/logos/AICodeMirror.jpg b/assets/partners/logos/AICodeMirror.jpg
new file mode 100644
index 0000000000..1c98b2238a
Binary files /dev/null and b/assets/partners/logos/AICodeMirror.jpg differ
diff --git a/assets/partners/logos/aigocode.png b/assets/partners/logos/aigocode.png
new file mode 100644
index 0000000000..6dd5965ac5
Binary files /dev/null and b/assets/partners/logos/aigocode.png differ
diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION
index e05e293553..e25dfa7197 100644
--- a/backend/cmd/server/VERSION
+++ b/backend/cmd/server/VERSION
@@ -1 +1 @@
-0.1.110.56
+0.1.112.1
\ No newline at end of file
diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go
index 64709b5b1c..3127a9c55f 100644
--- a/backend/cmd/server/wire.go
+++ b/backend/cmd/server/wire.go
@@ -43,6 +43,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// Server layer ProviderSet
server.ProviderSet,
+ // Payment providers
+ payment.ProvideRegistry,
+ payment.ProvideEncryptionKey,
+ payment.ProvideDefaultLoadBalancer,
+ service.ProvidePaymentConfigService,
+ service.ProvidePaymentOrderExpiryService,
+
// Privacy client factory for OpenAI training opt-out
providePrivacyClientFactory,
diff --git a/backend/go.mod b/backend/go.mod
index 7f11614a5d..66b6cc25b5 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -103,6 +103,7 @@ require (
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
+ github.com/google/subcommands v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
@@ -174,6 +175,7 @@ require (
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
+ golang.org/x/tools v0.41.0 // indirect
google.golang.org/grpc v1.75.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index 0c407c395c..9312af63e5 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -162,6 +162,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
+github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go
index 4ec038e068..a266d6a01c 100644
--- a/backend/internal/service/openai_codex_transform.go
+++ b/backend/internal/service/openai_codex_transform.go
@@ -124,6 +124,14 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact
"top_p",
"frequency_penalty",
"presence_penalty",
+ // prompt_cache_retention is a newer Responses API parameter (cache TTL).
+ // The ChatGPT internal Codex endpoint rejects it with
+ // "Unsupported parameter: prompt_cache_retention". Defense-in-depth
+ // for any OAuth path that reaches this transform — the Cursor
+ // Responses-shape short-circuit in ForwardAsChatCompletions strips
+ // it earlier too, but we keep this line so other OAuth callers are
+ // equally protected.
+ "prompt_cache_retention",
} {
if _, ok := reqBody[key]; ok {
delete(reqBody, key)
diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go
index 889ac61598..993ade0747 100644
--- a/backend/internal/service/openai_codex_transform_test.go
+++ b/backend/internal/service/openai_codex_transform_test.go
@@ -481,6 +481,26 @@ func TestExtractSystemMessagesFromInput(t *testing.T) {
})
}
+// TestApplyCodexOAuthTransform_StripsPromptCacheRetention is a regression
+// test: some clients (e.g. Cursor cloud via the Responses-shape compat path)
+// send prompt_cache_retention, but the ChatGPT internal Codex endpoint
+// rejects it with "Unsupported parameter: prompt_cache_retention".
+func TestApplyCodexOAuthTransform_StripsPromptCacheRetention(t *testing.T) {
+ reqBody := map[string]any{
+ "model": "gpt-5.1",
+ "prompt_cache_retention": "24h",
+ "input": []any{
+ map[string]any{"role": "user", "content": "hi"},
+ },
+ }
+
+ applyCodexOAuthTransform(reqBody, false, false)
+
+ _, stillThere := reqBody["prompt_cache_retention"]
+ require.False(t, stillThere,
+ "prompt_cache_retention must be stripped before forwarding to Codex upstream")
+}
+
func TestApplyCodexOAuthTransform_ExtractsSystemMessages(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.1",
diff --git a/backend/internal/service/openai_cursor_warmup_pipeline_test.go b/backend/internal/service/openai_cursor_warmup_pipeline_test.go
new file mode 100644
index 0000000000..19bb13d607
--- /dev/null
+++ b/backend/internal/service/openai_cursor_warmup_pipeline_test.go
@@ -0,0 +1,199 @@
+package service
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+// TestCursorMixedShapeDetection covers the core invariant of the Cursor
+// compatibility fix in ForwardAsChatCompletions: when a client POSTs a
+// Responses-shaped body (has `input`, no `messages`) to /v1/chat/completions,
+// the request must be forwarded as-is with only the `model` field rewritten.
+// The raw `input` array (including Cursor's 80KB system prompt) must not be
+// discarded or reshaped.
+//
+// Context:
+//
+// Before the fix, the handler unmarshaled the body into ChatCompletionsRequest,
+// which has no Input field, silently dropping Cursor's input. The subsequent
+// conversion produced `input: null`, which Codex upstreams reject with
+// "Invalid type for 'input': expected a string, but got an object".
+func TestCursorMixedShapeDetection(t *testing.T) {
+ // Representative Cursor cloud body — shape is what matters, content is
+ // abridged. Notice: `input` is a Responses-API array, there is no
+ // `messages` field at all, and `user`/`stream` are at the top level.
+ cursorBody := []byte(`{
+ "user": "85df22e7463ab6c2",
+ "model": "gpt-5.4",
+ "stream": true,
+ "input": [
+ {"role":"system","content":"You are GPT-5.4 running as a coding agent."},
+ {"role":"user","content":"hello"}
+ ],
+ "service_tier": "auto",
+ "reasoning": {"effort": "high"}
+ }`)
+
+ // --- Step 1: Shape detection (mirrors ForwardAsChatCompletions) ---
+ hasMessages := gjson.GetBytes(cursorBody, "messages").Exists()
+ hasInput := gjson.GetBytes(cursorBody, "input").Exists()
+ isResponsesShape := !hasMessages && hasInput
+
+ require.True(t, isResponsesShape,
+ "Cursor body must be detected as Responses-shape (has input, no messages)")
+
+ // --- Step 2: Model rewrite (mirrors the sjson.SetBytes branch) ---
+ const upstreamModel = "gpt-5.1-codex"
+ rewritten, err := sjson.SetBytes(cursorBody, "model", upstreamModel)
+ require.NoError(t, err)
+
+ // --- Step 3: Invariants of the rewritten body ---
+
+ // 3a. model must be rewritten to the upstream target.
+ assert.Equal(t, upstreamModel, gjson.GetBytes(rewritten, "model").String())
+
+ // 3b. input array must be preserved verbatim — no reshaping, no nulling.
+ inputResult := gjson.GetBytes(rewritten, "input")
+ require.True(t, inputResult.Exists(), "input field must still exist after rewrite")
+ require.True(t, inputResult.IsArray(), "input must still be an array (not null, not object)")
+
+ items := inputResult.Array()
+ require.Len(t, items, 2, "both input items must be preserved")
+ assert.Equal(t, "system", items[0].Get("role").String())
+ assert.Equal(t, "You are GPT-5.4 running as a coding agent.",
+ items[0].Get("content").String())
+ assert.Equal(t, "user", items[1].Get("role").String())
+ assert.Equal(t, "hello", items[1].Get("content").String())
+
+ // 3c. ALL other top-level fields must survive intact.
+ assert.Equal(t, "85df22e7463ab6c2", gjson.GetBytes(rewritten, "user").String())
+ assert.Equal(t, true, gjson.GetBytes(rewritten, "stream").Bool())
+ assert.Equal(t, "auto", gjson.GetBytes(rewritten, "service_tier").String())
+ assert.Equal(t, "high", gjson.GetBytes(rewritten, "reasoning.effort").String())
+
+ // 3d. Final upstream body must NOT contain the old "input":null pattern.
+ assert.NotContains(t, string(rewritten), `"input":null`,
+ "rewritten body must not collapse input to null")
+}
+
+// TestCursorMixedShapeDetection_NormalChatCompletionsUnaffected guards that
+// the shape detection does NOT misfire on a standard Chat Completions request
+// (one that has a `messages` array). Such requests must fall through to the
+// existing ChatCompletionsToResponses conversion path.
+func TestCursorMixedShapeDetection_NormalChatCompletionsUnaffected(t *testing.T) {
+ body := []byte(`{
+ "model": "gpt-4o",
+ "messages": [{"role":"user","content":"hi"}],
+ "stream": true
+ }`)
+
+ hasMessages := gjson.GetBytes(body, "messages").Exists()
+ hasInput := gjson.GetBytes(body, "input").Exists()
+ isResponsesShape := !hasMessages && hasInput
+
+ assert.False(t, isResponsesShape,
+ "standard Chat Completions body must NOT be detected as Responses-shape")
+}
+
+// TestCursorMixedShapeDetection_BothFieldsPrefersMessages guards the
+// ambiguous case where a client sends both `messages` and `input`. We fall
+// through to the normal conversion path (messages wins), since mixing the
+// two is almost certainly a client bug and messages is the documented
+// Chat Completions contract.
+func TestCursorMixedShapeDetection_BothFieldsPrefersMessages(t *testing.T) {
+ body := []byte(`{
+ "model": "gpt-4o",
+ "messages": [{"role":"user","content":"hi"}],
+ "input": [{"role":"user","content":"other"}]
+ }`)
+
+ hasMessages := gjson.GetBytes(body, "messages").Exists()
+ hasInput := gjson.GetBytes(body, "input").Exists()
+ isResponsesShape := !hasMessages && hasInput
+
+ assert.False(t, isResponsesShape,
+ "when both messages and input are present, must not take the Cursor shortcut")
+}
+
+// TestCursorMixedShapeDetection_EmptyBody ensures a body with neither
+// messages nor input is NOT taken as Cursor-shape (would hit the normal
+// conversion and fail on its own with a clearer error).
+func TestCursorMixedShapeDetection_EmptyBody(t *testing.T) {
+ body := []byte(`{"model":"gpt-5.4","stream":true}`)
+
+ hasMessages := gjson.GetBytes(body, "messages").Exists()
+ hasInput := gjson.GetBytes(body, "input").Exists()
+ isResponsesShape := !hasMessages && hasInput
+
+ assert.False(t, isResponsesShape,
+ "body with neither messages nor input must not be taken as Cursor shape")
+}
+
+// TestCursorMixedShape_JSONRoundtrip ensures the rewritten body is still
+// valid JSON and parseable back into a map without surprises — catches
+// any encoding drift from sjson.
+func TestCursorMixedShape_JSONRoundtrip(t *testing.T) {
+ cursorBody := []byte(`{"model":"gpt-5.4","stream":true,"input":[{"role":"user","content":"hi"}]}`)
+
+ rewritten, err := sjson.SetBytes(cursorBody, "model", "gpt-5.1-codex")
+ require.NoError(t, err)
+
+ var parsed map[string]any
+ require.NoError(t, json.Unmarshal(rewritten, &parsed))
+
+ assert.Equal(t, "gpt-5.1-codex", parsed["model"])
+ assert.Equal(t, true, parsed["stream"])
+
+ inputArr, ok := parsed["input"].([]any)
+ require.True(t, ok, "input must decode to a Go []any after round-trip")
+ require.Len(t, inputArr, 1)
+}
+
+// TestCursorMixedShape_StripsUnsupportedFields mirrors the strip loop in
+// ForwardAsChatCompletions (isResponsesShape branch). Cursor cloud sends
+// prompt_cache_retention, safety_identifier, metadata and stream_options
+// as top-level Responses API parameters, which Codex upstreams reject with
+// "Unsupported parameter: ...". The fix must remove them from the raw body
+// before it is forwarded, for BOTH OAuth and API Key account types.
+func TestCursorMixedShape_StripsUnsupportedFields(t *testing.T) {
+ cursorBody := []byte(`{
+ "model": "gpt-5.4",
+ "stream": true,
+ "prompt_cache_retention": "24h",
+ "safety_identifier": "cursor-user-xyz",
+ "metadata": {"trace_id":"abc","caller":"cursor"},
+ "stream_options": {"include_usage": true},
+ "input": [{"role":"user","content":"hi"}]
+ }`)
+
+ // Sanity: the test fixture contains every field the production code strips.
+ for _, field := range cursorResponsesUnsupportedFields {
+ require.True(t, gjson.GetBytes(cursorBody, field).Exists(),
+ "test fixture must contain %s", field)
+ }
+
+ // Run the exact same loop as the production code.
+ result := cursorBody
+ for _, field := range cursorResponsesUnsupportedFields {
+ if stripped, err := sjson.DeleteBytes(result, field); err == nil {
+ result = stripped
+ }
+ }
+
+ // All unsupported fields must be gone.
+ for _, field := range cursorResponsesUnsupportedFields {
+ assert.False(t, gjson.GetBytes(result, field).Exists(),
+ "%s must be stripped", field)
+ }
+
+ // Everything else must survive intact.
+ assert.Equal(t, "gpt-5.4", gjson.GetBytes(result, "model").String())
+ assert.Equal(t, true, gjson.GetBytes(result, "stream").Bool())
+ assert.True(t, gjson.GetBytes(result, "input").IsArray())
+ assert.Equal(t, "user", gjson.GetBytes(result, "input.0.role").String())
+}
diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go
index 25451b2b6b..eb5ce29850 100644
--- a/backend/internal/service/openai_gateway_chat_completions.go
+++ b/backend/internal/service/openai_gateway_chat_completions.go
@@ -16,9 +16,27 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
"go.uber.org/zap"
)
+// cursorResponsesUnsupportedFields are top-level Responses API parameters that
+// Codex upstreams reject with "Unsupported parameter: ...". They must be
+// stripped when forwarding a raw client body through the Responses-shape
+// short-circuit in ForwardAsChatCompletions (see isResponsesShape branch).
+// The normal Chat Completions → Responses conversion path is unaffected
+// because ChatCompletionsRequest has no fields for these parameters — unknown
+// fields are dropped naturally by json.Unmarshal. Kept semantically in sync
+// with the list in openai_gateway_service.go:2034 used by the /v1/responses
+// passthrough path.
+var cursorResponsesUnsupportedFields = []string{
+ "prompt_cache_retention",
+ "safety_identifier",
+ "metadata",
+ "stream_options",
+}
+
// ForwardAsChatCompletions accepts a Chat Completions request body, converts it
// to OpenAI Responses API format, forwards to the OpenAI upstream, and converts
// the response back to Chat Completions format. All account types (OAuth and API
@@ -55,13 +73,62 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
compatPromptCacheInjected = promptCacheKey != ""
}
- // 3. Convert to Responses and forward
- // ChatCompletionsToResponses always sets Stream=true (upstream always streams).
- responsesReq, err := apicompat.ChatCompletionsToResponses(&chatReq)
- if err != nil {
- return nil, fmt.Errorf("convert chat completions to responses: %w", err)
+ // 3. Build the upstream (Responses API) body.
+ //
+ // Cursor compatibility: some clients (notably Cursor cloud) send Responses
+ // API shaped bodies — `input: [...]` with no `messages` field — to the
+ // /v1/chat/completions URL. Running those through ChatCompletionsToResponses
+ // would silently drop Cursor's `input` array (the struct has no Input field)
+ // and produce `input: null`, which Codex upstreams reject with
+ // "Invalid type for 'input': expected a string, but got an object".
+ //
+ // Detect that shape and forward the raw body as-is, only rewriting `model`
+ // to the resolved upstream model. The downstream codex OAuth transform will
+ // still normalize store/stream/instructions/etc.
+ isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists()
+
+ var (
+ responsesReq *apicompat.ResponsesRequest
+ responsesBody []byte
+ err error
+ )
+ if isResponsesShape {
+ responsesBody, err = sjson.SetBytes(body, "model", upstreamModel)
+ if err != nil {
+ return nil, fmt.Errorf("rewrite model in responses-shape body: %w", err)
+ }
+ // Strip Responses API parameters that no Codex upstream accepts.
+ // Because this branch forwards the raw body (the normal path rebuilds
+ // it from ChatCompletionsRequest and drops unknown fields naturally),
+ // we must filter these fields explicitly here — otherwise the upstream
+ // rejects the request with "Unsupported parameter: ...".
+ for _, field := range cursorResponsesUnsupportedFields {
+ if stripped, derr := sjson.DeleteBytes(responsesBody, field); derr == nil {
+ responsesBody = stripped
+ }
+ }
+ // Minimal stub populated from the raw body so downstream billing
+ // propagation (ServiceTier, ReasoningEffort) keeps working.
+ responsesReq = &apicompat.ResponsesRequest{
+ Model: upstreamModel,
+ ServiceTier: gjson.GetBytes(responsesBody, "service_tier").String(),
+ }
+ if effort := gjson.GetBytes(responsesBody, "reasoning.effort").String(); effort != "" {
+ responsesReq.Reasoning = &apicompat.ResponsesReasoning{Effort: effort}
+ }
+ } else {
+ // Normal path: convert Chat Completions → Responses.
+ // ChatCompletionsToResponses always sets Stream=true (upstream always streams).
+ responsesReq, err = apicompat.ChatCompletionsToResponses(&chatReq)
+ if err != nil {
+ return nil, fmt.Errorf("convert chat completions to responses: %w", err)
+ }
+ responsesReq.Model = upstreamModel
+ responsesBody, err = json.Marshal(responsesReq)
+ if err != nil {
+ return nil, fmt.Errorf("marshal responses request: %w", err)
+ }
}
- responsesReq.Model = upstreamModel
logFields := []zap.Field{
zap.Int64("account_id", account.ID),
@@ -69,6 +136,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
zap.String("billing_model", billingModel),
zap.String("upstream_model", upstreamModel),
zap.Bool("stream", clientStream),
+ zap.Bool("responses_shape", isResponsesShape),
}
if compatPromptCacheInjected {
logFields = append(logFields,
@@ -78,12 +146,6 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
}
logger.L().Debug("openai chat_completions: model mapping applied", logFields...)
- // 4. Marshal Responses request body, then apply OAuth codex transform
- responsesBody, err := json.Marshal(responsesReq)
- if err != nil {
- return nil, fmt.Errorf("marshal responses request: %w", err)
- }
-
{
var reqBody map[string]any
if err := json.Unmarshal(responsesBody, &reqBody); err != nil {
diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go
index 7a4862d335..a72b9bbf4b 100644
--- a/backend/internal/service/openai_gateway_messages.go
+++ b/backend/internal/service/openai_gateway_messages.go
@@ -270,6 +270,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
var finalResponse *apicompat.ResponsesResponse
var usage OpenAIUsage
+ acc := apicompat.NewBufferedResponseAccumulator()
for scanner.Scan() {
line := scanner.Text()
@@ -288,8 +289,12 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
continue
}
+ // Accumulate delta content for fallback when terminal output is empty.
+ acc.ProcessEvent(&event)
+
// Terminal events carry the complete ResponsesResponse with output + usage.
- if (event.Type == "response.completed" || event.Type == "response.incomplete" || event.Type == "response.failed") &&
+ if (event.Type == "response.completed" || event.Type == "response.done" ||
+ event.Type == "response.incomplete" || event.Type == "response.failed") &&
event.Response != nil {
finalResponse = event.Response
if event.Response.Usage != nil {
@@ -318,6 +323,10 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
return nil, fmt.Errorf("upstream stream ended without terminal event")
}
+ // When the terminal event has an empty output array, reconstruct from
+ // accumulated delta events so the client receives the full content.
+ acc.SupplementResponseOutput(finalResponse)
+
anthropicResp := apicompat.ResponsesToAnthropic(finalResponse, originalModel)
if s.responseHeaderFilter != nil {
diff --git a/backend/internal/service/payment_config_plans.go b/backend/internal/service/payment_config_plans.go
index 6753071d3b..14dc1b9a9f 100644
--- a/backend/internal/service/payment_config_plans.go
+++ b/backend/internal/service/payment_config_plans.go
@@ -57,6 +57,7 @@ func validatePlanPatch(req UpdatePlanRequest) error {
return nil
}
+
// --- Plan CRUD ---
// PlanGroupInfo holds the group details needed for subscription plan display.
@@ -113,11 +114,11 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe
}
func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
- return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.ByCreatedAt()).All(ctx)
+ return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx)
}
func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
- return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.ByCreatedAt()).All(ctx)
+ return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx)
}
func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) {
diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go
index 4f5b57cc97..4d8009b7bd 100644
--- a/backend/internal/service/ratelimit_service.go
+++ b/backend/internal/service/ratelimit_service.go
@@ -142,11 +142,16 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
switch statusCode {
case 400:
- // 只有当错误信息包含 "organization has been disabled" 时才禁用
+ // "organization has been disabled" → 永久禁用
if strings.Contains(strings.ToLower(upstreamMsg), "organization has been disabled") {
msg := "Organization disabled (400): " + upstreamMsg
s.handleAuthError(ctx, account, msg)
shouldDisable = true
+ } else if account.Platform == PlatformAnthropic && strings.Contains(strings.ToLower(upstreamMsg), "credit balance") {
+ // Anthropic API key 余额不足(语义等同 402),停止调度
+ msg := "Credit balance exhausted (400): " + upstreamMsg
+ s.handleAuthError(ctx, account, msg)
+ shouldDisable = true
}
// 其他 400 错误(如参数问题)不处理,不禁用账号
case 401:
diff --git a/backend/migrations/092_payment_orders.sql b/backend/migrations/092_payment_orders.sql
new file mode 100644
index 0000000000..036e4dedc0
--- /dev/null
+++ b/backend/migrations/092_payment_orders.sql
@@ -0,0 +1,47 @@
+CREATE TABLE IF NOT EXISTS payment_orders (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ user_email VARCHAR(255) NOT NULL DEFAULT '',
+ user_name VARCHAR(100) NOT NULL DEFAULT '',
+ user_notes TEXT,
+ amount DECIMAL(20,2) NOT NULL,
+ pay_amount DECIMAL(20,2) NOT NULL,
+ fee_rate DECIMAL(10,4) NOT NULL DEFAULT 0,
+ recharge_code VARCHAR(64) NOT NULL DEFAULT '',
+ payment_type VARCHAR(30) NOT NULL DEFAULT '',
+ payment_trade_no VARCHAR(128) NOT NULL DEFAULT '',
+ pay_url TEXT,
+ qr_code TEXT,
+ qr_code_img TEXT,
+ order_type VARCHAR(20) NOT NULL DEFAULT 'balance',
+ plan_id BIGINT,
+ subscription_group_id BIGINT,
+ subscription_days INT,
+ provider_instance_id VARCHAR(64),
+ status VARCHAR(30) NOT NULL DEFAULT 'PENDING',
+ refund_amount DECIMAL(20,2) NOT NULL DEFAULT 0,
+ refund_reason TEXT,
+ refund_at TIMESTAMPTZ,
+ force_refund BOOLEAN NOT NULL DEFAULT FALSE,
+ refund_requested_at TIMESTAMPTZ,
+ refund_request_reason TEXT,
+ refund_requested_by VARCHAR(20),
+ expires_at TIMESTAMPTZ NOT NULL,
+ paid_at TIMESTAMPTZ,
+ completed_at TIMESTAMPTZ,
+ failed_at TIMESTAMPTZ,
+ failed_reason TEXT,
+ client_ip VARCHAR(50) NOT NULL DEFAULT '',
+ src_host VARCHAR(255) NOT NULL DEFAULT '',
+ src_url TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+-- Indexes
+CREATE INDEX IF NOT EXISTS idx_payment_orders_user_id ON payment_orders(user_id);
+CREATE INDEX IF NOT EXISTS idx_payment_orders_status ON payment_orders(status);
+CREATE INDEX IF NOT EXISTS idx_payment_orders_expires_at ON payment_orders(expires_at);
+CREATE INDEX IF NOT EXISTS idx_payment_orders_created_at ON payment_orders(created_at);
+CREATE INDEX IF NOT EXISTS idx_payment_orders_paid_at ON payment_orders(paid_at);
+CREATE INDEX IF NOT EXISTS idx_payment_orders_type_paid ON payment_orders(payment_type, paid_at);
+CREATE INDEX IF NOT EXISTS idx_payment_orders_order_type ON payment_orders(order_type);
diff --git a/backend/migrations/093_payment_audit_logs.sql b/backend/migrations/093_payment_audit_logs.sql
new file mode 100644
index 0000000000..d05b15ef83
--- /dev/null
+++ b/backend/migrations/093_payment_audit_logs.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS payment_audit_logs (
+ id BIGSERIAL PRIMARY KEY,
+ order_id VARCHAR(64) NOT NULL,
+ action VARCHAR(50) NOT NULL,
+ detail TEXT NOT NULL DEFAULT '',
+ operator VARCHAR(100) NOT NULL DEFAULT 'system',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_payment_audit_logs_order_id ON payment_audit_logs(order_id);
diff --git a/backend/migrations/094_removed_payment_channels.sql b/backend/migrations/094_removed_payment_channels.sql
new file mode 100644
index 0000000000..cb20234795
--- /dev/null
+++ b/backend/migrations/094_removed_payment_channels.sql
@@ -0,0 +1,4 @@
+-- Migration 092: payment_channels table was removed before release.
+-- This file is a no-op placeholder to maintain migration numbering continuity.
+-- The payment system now uses the existing channels table (migration 081).
+SELECT 1;
diff --git a/backend/migrations/095_subscription_plans.sql b/backend/migrations/095_subscription_plans.sql
new file mode 100644
index 0000000000..541d8f0c89
--- /dev/null
+++ b/backend/migrations/095_subscription_plans.sql
@@ -0,0 +1,18 @@
+CREATE TABLE IF NOT EXISTS subscription_plans (
+ id BIGSERIAL PRIMARY KEY,
+ group_id BIGINT NOT NULL,
+ name VARCHAR(100) NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ price DECIMAL(20,2) NOT NULL,
+ original_price DECIMAL(20,2),
+ validity_days INT NOT NULL DEFAULT 30,
+ validity_unit VARCHAR(10) NOT NULL DEFAULT 'day',
+ features TEXT NOT NULL DEFAULT '',
+ product_name VARCHAR(100) NOT NULL DEFAULT '',
+ for_sale BOOLEAN NOT NULL DEFAULT TRUE,
+ sort_order INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_subscription_plans_group_id ON subscription_plans(group_id);
+CREATE INDEX IF NOT EXISTS idx_subscription_plans_for_sale ON subscription_plans(for_sale);
diff --git a/backend/migrations/096_payment_provider_instances.sql b/backend/migrations/096_payment_provider_instances.sql
new file mode 100644
index 0000000000..bedd75df60
--- /dev/null
+++ b/backend/migrations/096_payment_provider_instances.sql
@@ -0,0 +1,15 @@
+CREATE TABLE IF NOT EXISTS payment_provider_instances (
+ id BIGSERIAL PRIMARY KEY,
+ provider_key VARCHAR(30) NOT NULL,
+ name VARCHAR(100) NOT NULL DEFAULT '',
+ config TEXT NOT NULL,
+ supported_types VARCHAR(200) NOT NULL DEFAULT '',
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ sort_order INT NOT NULL DEFAULT 0,
+ limits TEXT NOT NULL DEFAULT '',
+ refund_enabled BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_payment_provider_instances_provider_key ON payment_provider_instances(provider_key);
+CREATE INDEX IF NOT EXISTS idx_payment_provider_instances_enabled ON payment_provider_instances(enabled);
diff --git a/backend/migrations/097_fix_settings_updated_at_default.sql b/backend/migrations/097_fix_settings_updated_at_default.sql
new file mode 100644
index 0000000000..e1d6f9b935
--- /dev/null
+++ b/backend/migrations/097_fix_settings_updated_at_default.sql
@@ -0,0 +1,27 @@
+-- 097_fix_settings_updated_at_default.sql
+--
+-- 修复 settings.updated_at 列在历史实例上可能缺失 SQL DEFAULT 的问题。
+--
+-- 背景:
+-- 早期版本曾依赖 ent 自动迁移建表(ent 的 Default(time.Now) 仅是 Go 层默认值,
+-- 不会在 SQL 层落地为 DEFAULT),随后引入的 005_schema_parity.sql 使用了
+-- CREATE TABLE IF NOT EXISTS,对已存在的 settings 表不会重建,导致这部分实例
+-- 的 updated_at 列虽然是 NOT NULL,但缺少 SQL DEFAULT。
+--
+-- 后续 098_migrate_purchase_subscription_to_custom_menu.sql 是项目中唯一使用
+-- 原生 SQL INSERT INTO settings 的迁移(其余 settings 写入都走 ent / Go 层),
+-- 因此该 schema 缺陷直到 098 才会触发:
+-- "null value in column \"updated_at\" of relation \"settings\" violates not-null constraint"
+--
+-- 幂等性:
+-- - ALTER COLUMN ... SET DEFAULT NOW() 在已经具备相同默认值的实例上是无操作,
+-- 不会报错(PostgreSQL 允许重复设置相同的默认值)。
+-- - UPDATE 子句的 WHERE updated_at IS NULL 在健康实例上匹配 0 行,不影响数据。
+--
+-- 这样可以同时兼容:
+-- 1. 从未运行过旧版迁移的全新部署(005 已经把列建对,本迁移变成 no-op)。
+-- 2. 历史损坏实例(本迁移修复缺失的默认值,使后续 098 能够正常 INSERT)。
+
+ALTER TABLE settings ALTER COLUMN updated_at SET DEFAULT NOW();
+
+UPDATE settings SET updated_at = NOW() WHERE updated_at IS NULL;
diff --git a/backend/migrations/098_migrate_purchase_subscription_to_custom_menu.sql b/backend/migrations/098_migrate_purchase_subscription_to_custom_menu.sql
new file mode 100644
index 0000000000..1864459ecc
--- /dev/null
+++ b/backend/migrations/098_migrate_purchase_subscription_to_custom_menu.sql
@@ -0,0 +1,70 @@
+-- 096_migrate_purchase_subscription_to_custom_menu.sql
+--
+-- Migrates the legacy purchase_subscription_url setting into custom_menu_items.
+-- After migration, purchase_subscription_enabled is set to "false" and
+-- purchase_subscription_url is cleared.
+--
+-- Idempotent: skips if custom_menu_items already contains
+-- "migrated_purchase_subscription".
+
+DO $$
+DECLARE
+ v_enabled text;
+ v_url text;
+ v_raw text;
+ v_items jsonb;
+ v_new_item jsonb;
+BEGIN
+ -- Read legacy settings
+ SELECT value INTO v_enabled
+ FROM settings WHERE key = 'purchase_subscription_enabled';
+ SELECT value INTO v_url
+ FROM settings WHERE key = 'purchase_subscription_url';
+
+ -- Skip if not enabled or URL is empty
+ IF COALESCE(v_enabled, '') <> 'true' OR COALESCE(TRIM(v_url), '') = '' THEN
+ RETURN;
+ END IF;
+
+ -- Read current custom_menu_items
+ SELECT value INTO v_raw
+ FROM settings WHERE key = 'custom_menu_items';
+
+ IF COALESCE(v_raw, '') = '' OR v_raw = 'null' THEN
+ v_items := '[]'::jsonb;
+ ELSE
+ v_items := v_raw::jsonb;
+ END IF;
+
+ -- Skip if already migrated (item with id "migrated_purchase_subscription" exists)
+ IF EXISTS (
+ SELECT 1 FROM jsonb_array_elements(v_items) elem
+ WHERE elem ->> 'id' = 'migrated_purchase_subscription'
+ ) THEN
+ RETURN;
+ END IF;
+
+ -- Build the new menu item
+ v_new_item := jsonb_build_object(
+ 'id', 'migrated_purchase_subscription',
+ 'label', 'Purchase',
+ 'icon_svg', '',
+ 'url', TRIM(v_url),
+ 'visibility', 'user',
+ 'sort_order', 100
+ );
+
+ -- Append to array
+ v_items := v_items || jsonb_build_array(v_new_item);
+
+ -- Upsert custom_menu_items
+ INSERT INTO settings (key, value)
+ VALUES ('custom_menu_items', v_items::text)
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
+
+ -- Clear legacy settings
+ UPDATE settings SET value = 'false' WHERE key = 'purchase_subscription_enabled';
+ UPDATE settings SET value = '' WHERE key = 'purchase_subscription_url';
+
+ RAISE NOTICE '[migration-096] Migrated purchase_subscription_url (%) to custom_menu_items', v_url;
+END $$;
diff --git a/backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql b/backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql
new file mode 100644
index 0000000000..5361ad8166
--- /dev/null
+++ b/backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql
@@ -0,0 +1,51 @@
+-- 097_fix_migrated_purchase_menu_label_icon.sql
+--
+-- Fixes the custom menu item created by migration 096: updates the label
+-- from hardcoded English "Purchase" to "充值/订阅", and sets the icon_svg
+-- to a credit-card SVG matching the sidebar CreditCardIcon.
+--
+-- Idempotent: only modifies items where id = 'migrated_purchase_subscription'.
+
+DO $$
+DECLARE
+ v_raw text;
+ v_items jsonb;
+ v_idx int;
+ v_icon text;
+ v_elem jsonb;
+ v_i int := 0;
+BEGIN
+ SELECT value INTO v_raw
+ FROM settings WHERE key = 'custom_menu_items';
+
+ IF COALESCE(v_raw, '') = '' OR v_raw = 'null' THEN
+ RETURN;
+ END IF;
+
+ v_items := v_raw::jsonb;
+
+ -- Find the index of the migrated item by iterating the array
+ v_idx := NULL;
+ FOR v_elem IN SELECT jsonb_array_elements(v_items) LOOP
+ IF v_elem ->> 'id' = 'migrated_purchase_subscription' THEN
+ v_idx := v_i;
+ EXIT;
+ END IF;
+ v_i := v_i + 1;
+ END LOOP;
+
+ IF v_idx IS NULL THEN
+ RETURN; -- item not found, nothing to fix
+ END IF;
+
+ -- Credit card SVG (Heroicons outline, matches CreditCardIcon in AppSidebar)
+ v_icon := ' ';
+
+ -- Update label and icon_svg
+ v_items := jsonb_set(v_items, ARRAY[v_idx::text, 'label'], '"充值/订阅"'::jsonb);
+ v_items := jsonb_set(v_items, ARRAY[v_idx::text, 'icon_svg'], to_jsonb(v_icon));
+
+ UPDATE settings SET value = v_items::text WHERE key = 'custom_menu_items';
+
+ RAISE NOTICE '[migration-097] Fixed migrated_purchase_subscription: label=充值/订阅, icon=CreditCard SVG';
+END $$;
diff --git a/backend/migrations/100_remove_easypay_from_enabled_payment_types.sql b/backend/migrations/100_remove_easypay_from_enabled_payment_types.sql
new file mode 100644
index 0000000000..8128ed09ff
--- /dev/null
+++ b/backend/migrations/100_remove_easypay_from_enabled_payment_types.sql
@@ -0,0 +1,17 @@
+-- 098_remove_easypay_from_enabled_payment_types.sql
+--
+-- Removes "easypay" from ENABLED_PAYMENT_TYPES setting.
+-- "easypay" is a provider key, not a payment type. Valid payment types
+-- are: alipay, wxpay, alipay_direct, wxpay_direct, stripe.
+--
+-- Idempotent: safe to run multiple times.
+
+UPDATE settings
+ SET value = array_to_string(
+ array_remove(
+ string_to_array(value, ','),
+ 'easypay'
+ ), ','
+ )
+ WHERE key = 'ENABLED_PAYMENT_TYPES'
+ AND value LIKE '%easypay%';
diff --git a/backend/migrations/101_add_payment_mode.sql b/backend/migrations/101_add_payment_mode.sql
new file mode 100644
index 0000000000..eeb6ba7b2f
--- /dev/null
+++ b/backend/migrations/101_add_payment_mode.sql
@@ -0,0 +1,16 @@
+-- Add payment_mode field to payment_provider_instances
+-- Values: 'redirect' (hosted page redirect), 'api' (API call for QR/payurl), '' (default/N/A)
+ALTER TABLE payment_provider_instances ADD COLUMN IF NOT EXISTS payment_mode VARCHAR(20) NOT NULL DEFAULT '';
+
+-- Migrate existing data: easypay instances with 'easypay' in supported_types → redirect mode
+-- Remove 'easypay' from supported_types and set payment_mode = 'redirect'
+UPDATE payment_provider_instances
+SET payment_mode = 'redirect',
+ supported_types = TRIM(BOTH ',' FROM REPLACE(REPLACE(REPLACE(
+ supported_types, 'easypay,', ''), ',easypay', ''), 'easypay', ''))
+WHERE provider_key = 'easypay' AND supported_types LIKE '%easypay%';
+
+-- EasyPay instances without 'easypay' in supported_types → api mode
+UPDATE payment_provider_instances
+SET payment_mode = 'api'
+WHERE provider_key = 'easypay' AND payment_mode = '';
diff --git a/backend/migrations/102_add_out_trade_no_to_payment_orders.sql b/backend/migrations/102_add_out_trade_no_to_payment_orders.sql
new file mode 100644
index 0000000000..896c3c954d
--- /dev/null
+++ b/backend/migrations/102_add_out_trade_no_to_payment_orders.sql
@@ -0,0 +1,6 @@
+-- 100_add_out_trade_no_to_payment_orders.sql
+-- Adds out_trade_no column for external order ID used with payment providers.
+-- Allows webhook handlers to look up orders by external ID instead of embedding DB ID.
+
+ALTER TABLE payment_orders ADD COLUMN IF NOT EXISTS out_trade_no VARCHAR(64) NOT NULL DEFAULT '';
+CREATE INDEX IF NOT EXISTS paymentorder_out_trade_no ON payment_orders (out_trade_no);
diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue
index 800c1a5159..588832252c 100644
--- a/frontend/src/components/account/AccountUsageCell.vue
+++ b/frontend/src/components/account/AccountUsageCell.vue
@@ -1,5 +1,5 @@
-
+
@@ -472,7 +472,7 @@
diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue
index 36c7e27817..456b1da79a 100644
--- a/frontend/src/components/common/DataTable.vue
+++ b/frontend/src/components/common/DataTable.vue
@@ -1,5 +1,5 @@
-