From ccd42c1d1a9ff6e36add036dddfda2ecec767df2 Mon Sep 17 00:00:00 2001
From: weak-fox <827367480@qq.com>
Date: Mon, 23 Mar 2026 00:10:22 +0800
Subject: [PATCH 01/16] Retry OpenAI privacy opt-out after failed states
---
backend/internal/service/admin_service.go | 6 +-
.../service/openai_privacy_retry_test.go | 89 +++++++++++++++++++
.../service/openai_privacy_service.go | 13 +++
.../internal/service/token_refresh_service.go | 7 +-
4 files changed, 106 insertions(+), 9 deletions(-)
create mode 100644 backend/internal/service/openai_privacy_retry_test.go
diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go
index ccd681a3..294ff54a 100644
--- a/backend/internal/service/admin_service.go
+++ b/backend/internal/service/admin_service.go
@@ -2635,10 +2635,8 @@ func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Acc
if s.privacyClientFactory == nil {
return ""
}
- if account.Extra != nil {
- if _, ok := account.Extra["privacy_mode"]; ok {
- return ""
- }
+ if shouldSkipOpenAIPrivacyEnsure(account.Extra) {
+ return ""
}
token, _ := account.Credentials["access_token"].(string)
diff --git a/backend/internal/service/openai_privacy_retry_test.go b/backend/internal/service/openai_privacy_retry_test.go
new file mode 100644
index 00000000..24534ea9
--- /dev/null
+++ b/backend/internal/service/openai_privacy_retry_test.go
@@ -0,0 +1,89 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/imroc/req/v3"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAdminService_EnsureOpenAIPrivacy_RetriesNonSuccessModes(t *testing.T) {
+ t.Parallel()
+
+ for _, mode := range []string{PrivacyModeFailed, PrivacyModeCFBlocked} {
+ t.Run(mode, func(t *testing.T) {
+ t.Parallel()
+
+ privacyCalls := 0
+ svc := &adminServiceImpl{
+ accountRepo: &mockAccountRepoForGemini{},
+ privacyClientFactory: func(proxyURL string) (*req.Client, error) {
+ privacyCalls++
+ return nil, errors.New("factory failed")
+ },
+ }
+
+ account := &Account{
+ ID: 101,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{
+ "access_token": "token-1",
+ },
+ Extra: map[string]any{
+ "privacy_mode": mode,
+ },
+ }
+
+ got := svc.EnsureOpenAIPrivacy(context.Background(), account)
+
+ require.Equal(t, PrivacyModeFailed, got)
+ require.Equal(t, 1, privacyCalls)
+ })
+ }
+}
+
+func TestTokenRefreshService_ensureOpenAIPrivacy_RetriesNonSuccessModes(t *testing.T) {
+ t.Parallel()
+
+ cfg := &config.Config{
+ TokenRefresh: config.TokenRefreshConfig{
+ MaxRetries: 1,
+ RetryBackoffSeconds: 0,
+ },
+ }
+
+ for _, mode := range []string{PrivacyModeFailed, PrivacyModeCFBlocked} {
+ t.Run(mode, func(t *testing.T) {
+ t.Parallel()
+
+ service := NewTokenRefreshService(&tokenRefreshAccountRepo{}, nil, nil, nil, nil, nil, nil, cfg, nil)
+ privacyCalls := 0
+ service.SetPrivacyDeps(func(proxyURL string) (*req.Client, error) {
+ privacyCalls++
+ return nil, errors.New("factory failed")
+ }, nil)
+
+ account := &Account{
+ ID: 202,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{
+ "access_token": "token-2",
+ },
+ Extra: map[string]any{
+ "privacy_mode": mode,
+ },
+ }
+
+ service.ensureOpenAIPrivacy(context.Background(), account)
+
+ require.Equal(t, 1, privacyCalls)
+ })
+ }
+}
diff --git a/backend/internal/service/openai_privacy_service.go b/backend/internal/service/openai_privacy_service.go
index 90cd522d..9f4a6a86 100644
--- a/backend/internal/service/openai_privacy_service.go
+++ b/backend/internal/service/openai_privacy_service.go
@@ -22,6 +22,19 @@ const (
PrivacyModeCFBlocked = "training_set_cf_blocked"
)
+func shouldSkipOpenAIPrivacyEnsure(extra map[string]any) bool {
+ if extra == nil {
+ return false
+ }
+ raw, ok := extra["privacy_mode"]
+ if !ok {
+ return false
+ }
+ mode, _ := raw.(string)
+ mode = strings.TrimSpace(mode)
+ return mode != PrivacyModeFailed && mode != PrivacyModeCFBlocked
+}
+
// disableOpenAITraining calls ChatGPT settings API to turn off "Improve the model for everyone".
// Returns privacy_mode value: "training_off" on success, "cf_blocked" / "failed" on failure.
func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL string) string {
diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go
index 582afcd3..c0ab3593 100644
--- a/backend/internal/service/token_refresh_service.go
+++ b/backend/internal/service/token_refresh_service.go
@@ -442,11 +442,8 @@ func (s *TokenRefreshService) ensureOpenAIPrivacy(ctx context.Context, account *
if s.privacyClientFactory == nil {
return
}
- // 已设置过则跳过
- if account.Extra != nil {
- if _, ok := account.Extra["privacy_mode"]; ok {
- return
- }
+ if shouldSkipOpenAIPrivacyEnsure(account.Extra) {
+ return
}
token, _ := account.Credentials["access_token"].(string)
From 73d72651b48cf52edf4d6c75cb2cbb2c83b33bf0 Mon Sep 17 00:00:00 2001
From: Wang Lvyuan <74089601+LvyuanW@users.noreply.github.com>
Date: Mon, 23 Mar 2026 17:17:42 +0800
Subject: [PATCH 02/16] feat: support bulk OpenAI passthrough toggle
---
.../account/BulkEditAccountModal.vue | 437 +++++++++++-------
.../__tests__/BulkEditAccountModal.spec.ts | 75 ++-
2 files changed, 339 insertions(+), 173 deletions(-)
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index baecd6f6..68dc4fcc 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -31,6 +31,57 @@
+
+
@@ -89,100 +140,30 @@
role="group"
aria-labelledby="bulk-edit-model-restriction-label"
>
-
-
-
-
-
-
-
-
-
-
-
- {{ t('admin.accounts.selectAllowedModels') }}
-
-
-
-
-
-
- {{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
- {{
- t('admin.accounts.supportsAllModels')
- }}
+
+
+ {{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
+
@@ -821,7 +883,6 @@ import {
buildModelMappingObject as buildModelMappingPayload,
getPresetMappingsByPlatform
} from '@/composables/useModelWhitelist'
-
interface Props {
show: boolean
accountIds: number[]
@@ -843,6 +904,15 @@ const appStore = useAppStore()
// Platform awareness
const isMixedPlatform = computed(() => props.selectedPlatforms.length > 1)
+const allOpenAIPassthroughCapable = computed(() => {
+ return (
+ props.selectedPlatforms.length === 1 &&
+ props.selectedPlatforms[0] === 'openai' &&
+ props.selectedTypes.length > 0 &&
+ props.selectedTypes.every(t => t === 'oauth' || t === 'apikey')
+ )
+})
+
// 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
const allAnthropicOAuthOrSetupToken = computed(() => {
return (
@@ -886,6 +956,7 @@ const enablePriority = ref(false)
const enableRateMultiplier = ref(false)
const enableStatus = ref(false)
const enableGroups = ref(false)
+const enableOpenAIPassthrough = ref(false)
const enableRpmLimit = ref(false)
// State - field values
@@ -907,6 +978,7 @@ const priority = ref(1)
const rateMultiplier = ref(1)
const status = ref<'active' | 'inactive'>('active')
const groupIds = ref
([])
+const openaiPassthroughEnabled = ref(false)
const rpmLimitEnabled = ref(false)
const bulkBaseRpm = ref(null)
const bulkRpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
@@ -933,6 +1005,11 @@ const statusOptions = computed(() => [
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
])
+const isOpenAIModelRestrictionDisabled = computed(() =>
+ allOpenAIPassthroughCapable.value &&
+ enableOpenAIPassthrough.value &&
+ openaiPassthroughEnabled.value
+)
// Model mapping helpers
const addModelMapping = () => {
@@ -1015,6 +1092,12 @@ const buildUpdatePayload = (): Record | null => {
const updates: Record = {}
const credentials: Record = {}
let credentialsChanged = false
+ const ensureExtra = (): Record => {
+ if (!updates.extra) {
+ updates.extra = {}
+ }
+ return updates.extra as Record
+ }
if (enableProxy.value) {
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
@@ -1055,7 +1138,15 @@ const buildUpdatePayload = (): Record | null => {
}
}
- if (enableModelRestriction.value) {
+ if (enableOpenAIPassthrough.value) {
+ const extra = ensureExtra()
+ extra.openai_passthrough = openaiPassthroughEnabled.value
+ if (!openaiPassthroughEnabled.value) {
+ extra.openai_oauth_passthrough = false
+ }
+ }
+
+ if (enableModelRestriction.value && !isOpenAIModelRestrictionDisabled.value) {
// 统一使用 model_mapping 字段
if (modelRestrictionMode.value === 'whitelist') {
// 白名单模式:将模型转换为 model_mapping 格式(key=value)
@@ -1091,7 +1182,7 @@ const buildUpdatePayload = (): Record | null => {
// RPM limit settings (写入 extra 字段)
if (enableRpmLimit.value) {
- const extra: Record = {}
+ const extra = ensureExtra()
if (rpmLimitEnabled.value && bulkBaseRpm.value != null && bulkBaseRpm.value > 0) {
extra.base_rpm = bulkBaseRpm.value
extra.rpm_strategy = bulkRpmStrategy.value
@@ -1111,8 +1202,7 @@ const buildUpdatePayload = (): Record | null => {
// UMQ mode(独立于 RPM 保存)
if (userMsgQueueMode.value !== null) {
- if (!updates.extra) updates.extra = {}
- const umqExtra = updates.extra as Record
+ const umqExtra = ensureExtra()
umqExtra.user_msg_queue_mode = userMsgQueueMode.value // '' = 清除账号级覆盖
umqExtra.user_msg_queue_enabled = false // 清理旧字段(JSONB merge)
}
@@ -1168,6 +1258,7 @@ const handleSubmit = async () => {
const hasAnyFieldEnabled =
enableBaseUrl.value ||
+ enableOpenAIPassthrough.value ||
enableModelRestriction.value ||
enableCustomErrorCodes.value ||
enableInterceptWarmup.value ||
@@ -1269,10 +1360,12 @@ watch(
enableRateMultiplier.value = false
enableStatus.value = false
enableGroups.value = false
+ enableOpenAIPassthrough.value = false
enableRpmLimit.value = false
// Reset all values
baseUrl.value = ''
+ openaiPassthroughEnabled.value = false
modelRestrictionMode.value = 'whitelist'
allowedModels.value = []
modelMappings.value = []
diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
index 3598ff11..6458359e 100644
--- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
@@ -50,7 +50,21 @@ function mountModal(extraProps: Record = {}) {
stubs: {
BaseDialog: { template: '
' },
ConfirmDialog: true,
- Select: true,
+ Select: {
+ props: ['modelValue', 'options'],
+ emits: ['update:modelValue'],
+ template: `
+
+ `
+ },
ProxySelector: true,
GroupSelector: true,
Icon: true
@@ -115,4 +129,63 @@ describe('BulkEditAccountModal', () => {
}
})
})
+
+ it('OpenAI 账号批量编辑可开启自动透传', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['oauth']
+ })
+
+ await wrapper.get('#bulk-edit-openai-passthrough-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-openai-passthrough-toggle').trigger('click')
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
+ extra: {
+ openai_passthrough: true
+ }
+ })
+ })
+
+ it('OpenAI 账号批量编辑可关闭自动透传', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['apikey']
+ })
+
+ await wrapper.get('#bulk-edit-openai-passthrough-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
+ extra: {
+ openai_passthrough: false,
+ openai_oauth_passthrough: false
+ }
+ })
+ })
+
+ it('开启 OpenAI 自动透传时不再同时提交模型限制', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['oauth']
+ })
+
+ await wrapper.get('#bulk-edit-openai-passthrough-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-openai-passthrough-toggle').trigger('click')
+ await wrapper.get('#bulk-edit-model-restriction-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
+ extra: {
+ openai_passthrough: true
+ }
+ })
+ expect(wrapper.text()).toContain('admin.accounts.openai.modelRestrictionDisabledByPassthrough')
+ })
})
From fdad55956e3a98d68054676f556b3a24f6c0a807 Mon Sep 17 00:00:00 2001
From: Ikko Ashimine
Date: Wed, 25 Mar 2026 00:34:56 +0900
Subject: [PATCH 03/16] docs: add Japanese README
---
README.md | 2 +-
README_CN.md | 2 +-
README_JA.md | 585 +++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 587 insertions(+), 2 deletions(-)
create mode 100644 README_JA.md
diff --git a/README.md b/README.md
index 97dea364..41a5aca1 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
**AI API Gateway Platform for Subscription Quota Distribution**
-English | [中文](README_CN.md)
+English | [中文](README_CN.md) | [日本語](README_JA.md)
diff --git a/README_CN.md b/README_CN.md
index b1f5341d..3380cce7 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -12,7 +12,7 @@
**AI API 网关平台 - 订阅配额分发管理**
-[English](README.md) | 中文
+[English](README.md) | 中文 | [日本語](README_JA.md)
diff --git a/README_JA.md b/README_JA.md
new file mode 100644
index 00000000..c60b1a8e
--- /dev/null
+++ b/README_JA.md
@@ -0,0 +1,585 @@
+# Sub2API
+
+
+
+[](https://golang.org/)
+[](https://vuejs.org/)
+[](https://www.postgresql.org/)
+[](https://redis.io/)
+[](https://www.docker.com/)
+
+

+
+**サブスクリプションクォータ配分のための AI API ゲートウェイプラットフォーム**
+
+[English](README.md) | [中文](README_CN.md) | 日本語
+
+
+
+> **Sub2API が公式に使用しているドメインは `sub2api.org` と `pincc.ai` のみです。Sub2API の名称を使用している他のウェブサイトは、サードパーティによるデプロイやサービスであり、本プロジェクトとは一切関係がありません。ご利用の際はご自身で確認・判断をお願いします。**
+
+---
+
+## デモ
+
+Sub2API をオンラインでお試しください: **[https://demo.sub2api.org/](https://demo.sub2api.org/)**
+
+デモ用認証情報(共有デモ環境です。セルフホスト環境では**自動作成されません**):
+
+| メールアドレス | パスワード |
+|-------|----------|
+| admin@sub2api.org | admin123 |
+
+## 概要
+
+Sub2API は、AI 製品のサブスクリプションから API クォータを配分・管理するために設計された AI API ゲートウェイプラットフォームです。ユーザーはプラットフォームが生成した API キーを通じて上流の AI サービスにアクセスでき、プラットフォームは認証、課金、負荷分散、リクエスト転送を処理します。
+
+## 機能
+
+- **マルチアカウント管理** - 複数の上流アカウントタイプ(OAuth、APIキー)をサポート
+- **APIキー配布** - ユーザー向けの APIキーの生成と管理
+- **精密な課金** - トークンレベルの使用量追跡とコスト計算
+- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
+- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
+- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
+- **管理ダッシュボード** - 監視・管理のための Web インターフェース
+- **外部システム連携** - 外部システム(決済、チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
+
+## セルフホストが不要な方へ
+
+
+
+ |
+PinCC は Sub2API 上に構築された公式リレーサービスで、Claude Code、Codex、Gemini などの人気モデルへの安定したアクセスを提供します。デプロイやメンテナンスは不要で、すぐにご利用いただけます。 |
+
+
+
+## エコシステム
+
+Sub2API を拡張・統合するコミュニティプロジェクト:
+
+| プロジェクト | 説明 | 機能 |
+|---------|-------------|----------|
+| [Sub2ApiPay](https://github.com/touwaeriol/sub2apipay) | セルフサービス決済システム | セルフサービスによるチャージおよびサブスクリプション購入。YiPay プロトコル、WeChat Pay、Alipay、Stripe 対応。iframe での埋め込み可能 |
+| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | モバイル管理コンソール | ユーザー管理、アカウント管理、監視ダッシュボード、マルチバックエンド切り替えが可能なクロスプラットフォームアプリ(iOS/Android/Web)。Expo + React Native で構築 |
+
+## 技術スタック
+
+| コンポーネント | 技術 |
+|-----------|------------|
+| バックエンド | Go 1.25.7, Gin, Ent |
+| フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS |
+| データベース | PostgreSQL 15+ |
+| キャッシュ/キュー | Redis 7+ |
+
+---
+
+## Nginx リバースプロキシに関する注意
+
+Sub2API(または CRS)を Nginx でリバースプロキシし、Codex CLI と組み合わせて使用する場合、Nginx の `http` ブロックに以下の設定を追加してください:
+
+```nginx
+underscores_in_headers on;
+```
+
+Nginx はデフォルトでアンダースコアを含むヘッダー(例: `session_id`)を破棄するため、マルチアカウント構成でのスティッキーセッションルーティングに支障をきたします。
+
+---
+
+## デプロイ
+
+### 方法1: スクリプトによるインストール(推奨)
+
+GitHub Releases からビルド済みバイナリをダウンロードするワンクリックインストールスクリプトです。
+
+#### 前提条件
+
+- Linux サーバー(amd64 または arm64)
+- PostgreSQL 15+(インストール済みかつ稼働中)
+- Redis 7+(インストール済みかつ稼働中)
+- root 権限
+
+#### インストール手順
+
+```bash
+curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash
+```
+
+スクリプトは以下を実行します:
+1. システムアーキテクチャの検出
+2. 最新リリースのダウンロード
+3. バイナリを `/opt/sub2api` にインストール
+4. systemd サービスの作成
+5. システムユーザーと権限の設定
+
+#### インストール後の作業
+
+```bash
+# 1. サービスを起動
+sudo systemctl start sub2api
+
+# 2. 起動時の自動起動を有効化
+sudo systemctl enable sub2api
+
+# 3. ブラウザでセットアップウィザードを開く
+# http://YOUR_SERVER_IP:8080
+```
+
+セットアップウィザードでは以下の設定を行います:
+- データベース設定
+- Redis 設定
+- 管理者アカウントの作成
+
+#### アップグレード
+
+**管理ダッシュボード**の左上にある**アップデートを確認**ボタンをクリックすることで、ダッシュボードから直接アップグレードできます。
+
+Web インターフェースでは以下が可能です:
+- 新しいバージョンの自動確認
+- ワンクリックでのアップデートのダウンロードと適用
+- 必要に応じたロールバック
+
+#### よく使うコマンド
+
+```bash
+# ステータスを確認
+sudo systemctl status sub2api
+
+# ログを表示
+sudo journalctl -u sub2api -f
+
+# サービスを再起動
+sudo systemctl restart sub2api
+
+# アンインストール
+curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y
+```
+
+---
+
+### 方法2: Docker Compose(推奨)
+
+PostgreSQL と Redis のコンテナを含む Docker Compose でデプロイします。
+
+#### 前提条件
+
+- Docker 20.10+
+- Docker Compose v2+
+
+#### クイックスタート(ワンクリックデプロイ)
+
+自動デプロイスクリプトを使用して簡単にセットアップできます:
+
+```bash
+# デプロイ用ディレクトリを作成
+mkdir -p sub2api-deploy && cd sub2api-deploy
+
+# デプロイ準備スクリプトをダウンロードして実行
+curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash
+
+# サービスを起動
+docker compose up -d
+
+# ログを表示
+docker compose logs -f sub2api
+```
+
+**スクリプトの動作内容:**
+- `docker-compose.local.yml`(`docker-compose.yml` として保存)と `.env.example` をダウンロード
+- セキュアな認証情報(JWT_SECRET、TOTP_ENCRYPTION_KEY、POSTGRES_PASSWORD)を自動生成
+- 自動生成されたシークレットで `.env` ファイルを作成
+- データディレクトリを作成(バックアップ・移行が容易なローカルディレクトリを使用)
+- 生成された認証情報を参照用に表示
+
+#### 手動デプロイ
+
+手動でセットアップする場合:
+
+```bash
+# 1. リポジトリをクローン
+git clone https://github.com/Wei-Shaw/sub2api.git
+cd sub2api/deploy
+
+# 2. 環境設定ファイルをコピー
+cp .env.example .env
+
+# 3. 設定を編集(セキュアなパスワードを生成)
+nano .env
+```
+
+**`.env` の必須設定:**
+
+```bash
+# PostgreSQL パスワード(必須)
+POSTGRES_PASSWORD=your_secure_password_here
+
+# JWT シークレット(推奨 - 再起動後もユーザーのログイン状態を保持)
+JWT_SECRET=your_jwt_secret_here
+
+# TOTP 暗号化キー(推奨 - 再起動後も二要素認証を維持)
+TOTP_ENCRYPTION_KEY=your_totp_key_here
+
+# オプション: 管理者アカウント
+ADMIN_EMAIL=admin@example.com
+ADMIN_PASSWORD=your_admin_password
+
+# オプション: カスタムポート
+SERVER_PORT=8080
+```
+
+**セキュアなシークレットの生成方法:**
+```bash
+# JWT_SECRET を生成
+openssl rand -hex 32
+
+# TOTP_ENCRYPTION_KEY を生成
+openssl rand -hex 32
+
+# POSTGRES_PASSWORD を生成
+openssl rand -hex 32
+```
+
+```bash
+# 4. データディレクトリを作成(ローカルバージョンの場合)
+mkdir -p data postgres_data redis_data
+
+# 5. すべてのサービスを起動
+# オプション A: ローカルディレクトリバージョン(推奨 - 移行が容易)
+docker compose -f docker-compose.local.yml up -d
+
+# オプション B: 名前付きボリュームバージョン(シンプルなセットアップ)
+docker compose up -d
+
+# 6. ステータスを確認
+docker compose -f docker-compose.local.yml ps
+
+# 7. ログを表示
+docker compose -f docker-compose.local.yml logs -f sub2api
+```
+
+#### デプロイバージョン
+
+| バージョン | データストレージ | 移行 | 推奨用途 |
+|---------|-------------|-----------|----------|
+| **docker-compose.local.yml** | ローカルディレクトリ | ✅ 容易(ディレクトリ全体を tar) | 本番環境、頻繁なバックアップ |
+| **docker-compose.yml** | 名前付きボリューム | ⚠️ docker コマンドが必要 | シンプルなセットアップ |
+
+**推奨:** データ管理が容易な `docker-compose.local.yml`(スクリプトによるデプロイ)を使用してください。
+
+#### アクセス
+
+ブラウザで `http://YOUR_SERVER_IP:8080` を開いてください。
+
+管理者パスワードが自動生成された場合は、ログで確認できます:
+```bash
+docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
+```
+
+#### アップグレード
+
+```bash
+# 最新イメージをプルしてコンテナを再作成
+docker compose -f docker-compose.local.yml pull
+docker compose -f docker-compose.local.yml up -d
+```
+
+#### 簡単な移行(ローカルディレクトリバージョン)
+
+`docker-compose.local.yml` を使用している場合、新しいサーバーへの移行が簡単です:
+
+```bash
+# 移行元サーバーにて
+docker compose -f docker-compose.local.yml down
+cd ..
+tar czf sub2api-complete.tar.gz sub2api-deploy/
+
+# 新しいサーバーに転送
+scp sub2api-complete.tar.gz user@new-server:/path/
+
+# 移行先サーバーにて
+tar xzf sub2api-complete.tar.gz
+cd sub2api-deploy/
+docker compose -f docker-compose.local.yml up -d
+```
+
+#### よく使うコマンド
+
+```bash
+# すべてのサービスを停止
+docker compose -f docker-compose.local.yml down
+
+# 再起動
+docker compose -f docker-compose.local.yml restart
+
+# すべてのログを表示
+docker compose -f docker-compose.local.yml logs -f
+
+# すべてのデータを削除(注意!)
+docker compose -f docker-compose.local.yml down
+rm -rf data/ postgres_data/ redis_data/
+```
+
+---
+
+### 方法3: ソースからビルド
+
+開発やカスタマイズのためにソースコードからビルドして実行します。
+
+#### 前提条件
+
+- Go 1.21+
+- Node.js 18+
+- PostgreSQL 15+
+- Redis 7+
+
+#### ビルド手順
+
+```bash
+# 1. リポジトリをクローン
+git clone https://github.com/Wei-Shaw/sub2api.git
+cd sub2api
+
+# 2. pnpm をインストール(未インストールの場合)
+npm install -g pnpm
+
+# 3. フロントエンドをビルド
+cd frontend
+pnpm install
+pnpm run build
+# 出力先: ../backend/internal/web/dist/
+
+# 4. フロントエンドを組み込んだバックエンドをビルド
+cd ../backend
+go build -tags embed -o sub2api ./cmd/server
+
+# 5. 設定ファイルを作成
+cp ../deploy/config.example.yaml ./config.yaml
+
+# 6. 設定を編集
+nano config.yaml
+```
+
+> **注意:** `-tags embed` フラグはフロントエンドをバイナリに組み込みます。このフラグがない場合、バイナリはフロントエンド UI を提供しません。
+
+**`config.yaml` の主要設定:**
+
+```yaml
+server:
+ host: "0.0.0.0"
+ port: 8080
+ mode: "release"
+
+database:
+ host: "localhost"
+ port: 5432
+ user: "postgres"
+ password: "your_password"
+ dbname: "sub2api"
+
+redis:
+ host: "localhost"
+ port: 6379
+ password: ""
+
+jwt:
+ secret: "change-this-to-a-secure-random-string"
+ expire_hour: 24
+
+default:
+ user_concurrency: 5
+ user_balance: 0
+ api_key_prefix: "sk-"
+ rate_multiplier: 1.0
+```
+
+### Sora ステータス(一時的に利用不可)
+
+> ⚠️ Sora 関連の機能は、上流統合およびメディア配信の技術的問題により一時的に利用できません。
+> 現時点では本番環境で Sora に依存しないでください。
+> 既存の `gateway.sora_*` 設定キーは予約されていますが、これらの問題が解決されるまで有効にならない場合があります。
+
+`config.yaml` では追加のセキュリティ関連オプションも利用できます:
+
+- `cors.allowed_origins` - CORS 許可リスト
+- `security.url_allowlist` - 上流/価格/CRS ホストの許可リスト
+- `security.url_allowlist.enabled` - URL バリデーションの無効化(注意して使用)
+- `security.url_allowlist.allow_insecure_http` - バリデーション無効時に HTTP URL を許可
+- `security.url_allowlist.allow_private_hosts` - プライベート/ローカル IP アドレスを許可
+- `security.response_headers.enabled` - 設定可能なレスポンスヘッダーフィルタリングを有効化(無効時はデフォルトの許可リストを使用)
+- `security.csp` - Content-Security-Policy ヘッダーの制御
+- `billing.circuit_breaker` - 課金エラー時にフェイルクローズ
+- `server.trusted_proxies` - X-Forwarded-For パースの有効化
+- `turnstile.required` - リリースモードでの Turnstile 必須化
+
+**⚠️ セキュリティ警告: HTTP URL 設定**
+
+`security.url_allowlist.enabled=false` の場合、システムはデフォルトで最小限の URL バリデーションを行い、**HTTP URL を拒否**して HTTPS のみを許可します。HTTP URL を許可するには(開発環境や内部テスト用など)、以下を明示的に設定する必要があります:
+
+```yaml
+security:
+ url_allowlist:
+ enabled: false # 許可リストチェックを無効化
+ allow_insecure_http: true # HTTP URL を許可(⚠️ セキュリティリスクあり)
+```
+
+**または環境変数で設定:**
+
+```bash
+SECURITY_URL_ALLOWLIST_ENABLED=false
+SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
+```
+
+**HTTP を許可するリスク:**
+- API キーとデータが**平文**で送信される(傍受の危険性)
+- **中間者攻撃(MITM)**を受けやすい
+- **本番環境には不適切**
+
+**HTTP を使用すべき場面:**
+- ✅ ローカルサーバーでの開発・テスト(http://localhost)
+- ✅ 信頼できるエンドポイントを持つ内部ネットワーク
+- ✅ HTTPS 取得前のアカウント接続テスト
+- ❌ 本番環境(HTTPS のみを使用)
+
+**この設定なしで表示されるエラー例:**
+```
+Invalid base URL: invalid url scheme: http
+```
+
+URL バリデーションまたはレスポンスヘッダーフィルタリングを無効にする場合は、ネットワーク層を強化してください:
+- 上流ドメイン/IP のエグレス許可リストを適用
+- プライベート/ループバック/リンクローカル範囲をブロック
+- TLS のみのアウトバウンドトラフィックを強制
+- プロキシで機密性の高い上流レスポンスヘッダーを除去
+
+```bash
+# 6. アプリケーションを実行
+./sub2api
+```
+
+#### 開発モード
+
+```bash
+# バックエンド(ホットリロード付き)
+cd backend
+go run ./cmd/server
+
+# フロントエンド(ホットリロード付き)
+cd frontend
+pnpm run dev
+```
+
+#### コード生成
+
+`backend/ent/schema` を編集した場合、Ent + Wire を再生成してください:
+
+```bash
+cd backend
+go generate ./ent
+go generate ./cmd/server
+```
+
+---
+
+## シンプルモード
+
+シンプルモードは、フル SaaS 機能を必要とせず、素早くアクセスしたい個人開発者や社内チーム向けに設計されています。
+
+- 有効化: 環境変数 `RUN_MODE=simple` を設定
+- 違い: SaaS 関連機能を非表示にし、課金プロセスをスキップ
+- セキュリティに関する注意: 本番環境では `SIMPLE_MODE_CONFIRM=true` も設定する必要があります
+
+---
+
+## Antigravity サポート
+
+Sub2API は [Antigravity](https://antigravity.so/) アカウントをサポートしています。認証後、Claude および Gemini モデル用の専用エンドポイントが利用可能になります。
+
+### 専用エンドポイント
+
+| エンドポイント | モデル |
+|----------|-------|
+| `/antigravity/v1/messages` | Claude モデル |
+| `/antigravity/v1beta/` | Gemini モデル |
+
+### Claude Code の設定
+
+```bash
+export ANTHROPIC_BASE_URL="http://localhost:8080/antigravity"
+export ANTHROPIC_AUTH_TOKEN="sk-xxx"
+```
+
+### ハイブリッドスケジューリングモード
+
+Antigravity アカウントはオプションの**ハイブリッドスケジューリング**をサポートしています。有効にすると、汎用エンドポイント `/v1/messages` および `/v1beta/` も Antigravity アカウントにリクエストをルーティングします。
+
+> **⚠️ 警告**: Anthropic Claude と Antigravity Claude は**同じ会話コンテキスト内で混在させることはできません**。グループを使用して適切に分離してください。
+
+### 既知の問題
+
+Claude Code では、Plan Mode を自動的に終了できません。(通常、ネイティブの Claude API を使用する場合、計画が完了すると Claude Code はユーザーに計画を承認または拒否するオプションをポップアップ表示します。)
+
+**回避策**: `Shift + Tab` を押して手動で Plan Mode を終了し、計画を承認または拒否するためのレスポンスを入力してください。
+
+---
+
+## プロジェクト構成
+
+```
+sub2api/
+├── backend/ # Go バックエンドサービス
+│ ├── cmd/server/ # アプリケーションエントリ
+│ ├── internal/ # 内部モジュール
+│ │ ├── config/ # 設定
+│ │ ├── model/ # データモデル
+│ │ ├── service/ # ビジネスロジック
+│ │ ├── handler/ # HTTP ハンドラー
+│ │ └── gateway/ # API ゲートウェイコア
+│ └── resources/ # 静的リソース
+│
+├── frontend/ # Vue 3 フロントエンド
+│ └── src/
+│ ├── api/ # API 呼び出し
+│ ├── stores/ # 状態管理
+│ ├── views/ # ページコンポーネント
+│ └── components/ # 再利用可能なコンポーネント
+│
+└── deploy/ # デプロイファイル
+ ├── docker-compose.yml # Docker Compose 設定
+ ├── .env.example # Docker Compose 用環境変数
+ ├── config.example.yaml # バイナリデプロイ用フル設定ファイル
+ └── install.sh # ワンクリックインストールスクリプト
+```
+
+## 免責事項
+
+> **本プロジェクトをご利用の前に、以下をよくお読みください:**
+>
+> :rotating_light: **利用規約違反のリスク**: 本プロジェクトの使用は Anthropic の利用規約に違反する可能性があります。使用前に Anthropic のユーザー契約をよくお読みください。本プロジェクトの使用に起因するすべてのリスクは、ユーザー自身が負うものとします。
+>
+> :book: **免責事項**: 本プロジェクトは技術的な学習および研究目的のみで提供されています。作者は、本プロジェクトの使用によるアカウント停止、サービス中断、その他の損失について一切の責任を負いません。
+
+---
+
+## スター履歴
+
+
+
+
+
+
+
+
+
+---
+
+## ライセンス
+
+MIT License
+
+---
+
+
+
+**このプロジェクトが役に立ったら、ぜひスターをお願いします!**
+
+
From c2965c0fb0c86133f3d8f1e48037f95a29def876 Mon Sep 17 00:00:00 2001
From: QTom
Date: Wed, 25 Mar 2026 13:05:47 +0800
Subject: [PATCH 04/16] =?UTF-8?q?feat(antigravity):=20=E8=87=AA=E5=8A=A8?=
=?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=9A=90=E7=A7=81=E5=B9=B6=E6=94=AF=E6=8C=81?=
=?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=89=8B=E5=8A=A8=E9=87=8D=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增 Antigravity OAuth 隐私设置能力,在账号创建、刷新、导入和后台
Token 刷新路径自动调用 setUserSettings + fetchUserInfo 关闭遥测;
持久化后同步内存 Extra,错误处理改为日志记录。
Made-with: Cursor
---
.../internal/handler/admin/account_data.go | 27 +++-
.../internal/handler/admin/account_handler.go | 63 ++++++++
.../handler/admin/admin_service_stub_test.go | 8 ++
backend/internal/pkg/antigravity/client.go | 136 ++++++++++++++++++
backend/internal/server/routes/admin.go | 1 +
backend/internal/service/admin_service.go | 80 +++++++++++
.../service/antigravity_privacy_service.go | 81 +++++++++++
.../antigravity_privacy_service_test.go | 18 +++
.../internal/service/token_refresh_service.go | 52 ++++++-
frontend/src/api/admin/accounts.ts | 13 +-
.../admin/account/AccountActionMenu.vue | 7 +-
.../components/common/PlatformTypeBadge.vue | 15 +-
frontend/src/i18n/locales/en.ts | 3 +
frontend/src/i18n/locales/zh.ts | 3 +
frontend/src/views/admin/AccountsView.vue | 13 +-
15 files changed, 512 insertions(+), 8 deletions(-)
create mode 100644 backend/internal/service/antigravity_privacy_service.go
create mode 100644 backend/internal/service/antigravity_privacy_service_test.go
diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go
index 322ae590..12139b51 100644
--- a/backend/internal/handler/admin/account_data.go
+++ b/backend/internal/handler/admin/account_data.go
@@ -267,6 +267,9 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
}
}
+ // 收集需要异步设置隐私的 Antigravity OAuth 账号
+ var privacyAccounts []*service.Account
+
for i := range dataPayload.Accounts {
item := dataPayload.Accounts[i]
if err := validateDataAccount(item); err != nil {
@@ -314,7 +317,8 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
SkipDefaultGroupBind: skipDefaultGroupBind,
}
- if _, err := h.adminService.CreateAccount(ctx, accountInput); err != nil {
+ created, err := h.adminService.CreateAccount(ctx, accountInput)
+ if err != nil {
result.AccountFailed++
result.Errors = append(result.Errors, DataImportError{
Kind: "account",
@@ -323,9 +327,30 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
})
continue
}
+ // 收集 Antigravity OAuth 账号,稍后异步设置隐私
+ if created.Platform == service.PlatformAntigravity && created.Type == service.AccountTypeOAuth {
+ privacyAccounts = append(privacyAccounts, created)
+ }
result.AccountCreated++
}
+ // 异步设置 Antigravity 隐私,避免大量导入时阻塞请求
+ if len(privacyAccounts) > 0 {
+ adminSvc := h.adminService
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("import_antigravity_privacy_panic", "recover", r)
+ }
+ }()
+ bgCtx := context.Background()
+ for _, acc := range privacyAccounts {
+ adminSvc.ForceAntigravityPrivacy(bgCtx, acc)
+ }
+ slog.Info("import_antigravity_privacy_done", "count", len(privacyAccounts))
+ }()
+ }
+
return result, nil
}
diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go
index 9eaf0bfd..6711abae 100644
--- a/backend/internal/handler/admin/account_handler.go
+++ b/backend/internal/handler/admin/account_handler.go
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"log"
+ "log/slog"
"net/http"
"strconv"
"strings"
@@ -536,6 +537,8 @@ func (h *AccountHandler) Create(c *gin.Context) {
if execErr != nil {
return nil, execErr
}
+ // Antigravity OAuth: 新账号直接设置隐私
+ h.adminService.ForceAntigravityPrivacy(ctx, account)
return h.buildAccountResponseWithRuntime(ctx, account), nil
})
if err != nil {
@@ -883,6 +886,8 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
// OpenAI OAuth: 刷新成功后检查并设置 privacy_mode
h.adminService.EnsureOpenAIPrivacy(ctx, updatedAccount)
+ // Antigravity OAuth: 刷新成功后检查并设置 privacy_mode
+ h.adminService.EnsureAntigravityPrivacy(ctx, updatedAccount)
return updatedAccount, "", nil
}
@@ -1154,6 +1159,8 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
success := 0
failed := 0
results := make([]gin.H, 0, len(req.Accounts))
+ // 收集需要异步设置隐私的 Antigravity OAuth 账号
+ var privacyAccounts []*service.Account
for _, item := range req.Accounts {
if item.RateMultiplier != nil && *item.RateMultiplier < 0 {
@@ -1196,6 +1203,10 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
})
continue
}
+ // 收集 Antigravity OAuth 账号,稍后异步设置隐私
+ if account.Platform == service.PlatformAntigravity && account.Type == service.AccountTypeOAuth {
+ privacyAccounts = append(privacyAccounts, account)
+ }
success++
results = append(results, gin.H{
"name": item.Name,
@@ -1204,6 +1215,22 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
})
}
+ // 异步设置 Antigravity 隐私,避免批量创建时阻塞请求
+ if len(privacyAccounts) > 0 {
+ adminSvc := h.adminService
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("batch_create_antigravity_privacy_panic", "recover", r)
+ }
+ }()
+ bgCtx := context.Background()
+ for _, acc := range privacyAccounts {
+ adminSvc.ForceAntigravityPrivacy(bgCtx, acc)
+ }
+ }()
+ }
+
return gin.H{
"success": success,
"failed": failed,
@@ -1869,6 +1896,42 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
response.Success(c, models)
}
+// SetPrivacy handles setting privacy for a single Antigravity OAuth account
+// POST /api/v1/admin/accounts/:id/set-privacy
+func (h *AccountHandler) SetPrivacy(c *gin.Context) {
+ accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ response.BadRequest(c, "Invalid account ID")
+ return
+ }
+ account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
+ if err != nil {
+ response.NotFound(c, "Account not found")
+ return
+ }
+ if account.Platform != service.PlatformAntigravity || account.Type != service.AccountTypeOAuth {
+ response.BadRequest(c, "Only Antigravity OAuth accounts support privacy setting")
+ return
+ }
+ mode := h.adminService.ForceAntigravityPrivacy(c.Request.Context(), account)
+ if mode == "" {
+ response.BadRequest(c, "Cannot set privacy: missing access_token")
+ return
+ }
+ // 从 DB 重新读取以确保返回最新状态
+ updated, err := h.adminService.GetAccount(c.Request.Context(), accountID)
+ if err != nil {
+ // 隐私已设置成功但读取失败,回退到内存更新
+ if account.Extra == nil {
+ account.Extra = make(map[string]any)
+ }
+ account.Extra["privacy_mode"] = mode
+ response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
+ return
+ }
+ response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updated))
+}
+
// RefreshTier handles refreshing Google One tier for a single account
// POST /api/v1/admin/accounts/:id/refresh-tier
func (h *AccountHandler) RefreshTier(c *gin.Context) {
diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go
index 4ed0a623..745c5610 100644
--- a/backend/internal/handler/admin/admin_service_stub_test.go
+++ b/backend/internal/handler/admin/admin_service_stub_test.go
@@ -445,6 +445,14 @@ func (s *stubAdminService) EnsureOpenAIPrivacy(ctx context.Context, account *ser
return ""
}
+func (s *stubAdminService) EnsureAntigravityPrivacy(ctx context.Context, account *service.Account) string {
+ return ""
+}
+
+func (s *stubAdminService) ForceAntigravityPrivacy(ctx context.Context, account *service.Account) string {
+ return ""
+}
+
func (s *stubAdminService) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*service.ReplaceUserGroupResult, error) {
return &service.ReplaceUserGroupResult{MigratedKeys: 0}, nil
}
diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go
index f24ff5a8..11138679 100644
--- a/backend/internal/pkg/antigravity/client.go
+++ b/backend/internal/pkg/antigravity/client.go
@@ -704,3 +704,139 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
return nil, nil, lastErr
}
+
+// ── Privacy API ──────────────────────────────────────────────────────
+
+// privacyBaseURL 隐私设置 API 仅使用 daily 端点(与 Antigravity 客户端行为一致)
+const privacyBaseURL = antigravityDailyBaseURL
+
+// SetUserSettingsRequest setUserSettings 请求体
+type SetUserSettingsRequest struct {
+ UserSettings map[string]any `json:"user_settings"`
+}
+
+// FetchUserInfoRequest fetchUserInfo 请求体
+type FetchUserInfoRequest struct {
+ Project string `json:"project"`
+}
+
+// FetchUserInfoResponse fetchUserInfo 响应体
+type FetchUserInfoResponse struct {
+ UserSettings map[string]any `json:"userSettings,omitempty"`
+ RegionCode string `json:"regionCode,omitempty"`
+}
+
+// IsPrivate 判断隐私是否已设置:userSettings 为空或不含 telemetryEnabled 表示已设置
+func (r *FetchUserInfoResponse) IsPrivate() bool {
+ if r == nil || r.UserSettings == nil {
+ return true
+ }
+ _, hasTelemetry := r.UserSettings["telemetryEnabled"]
+ return !hasTelemetry
+}
+
+// SetUserSettingsResponse setUserSettings 响应体
+type SetUserSettingsResponse struct {
+ UserSettings map[string]any `json:"userSettings,omitempty"`
+}
+
+// IsSuccess 判断 setUserSettings 是否成功:返回 {"userSettings":{}} 且无 telemetryEnabled
+func (r *SetUserSettingsResponse) IsSuccess() bool {
+ if r == nil {
+ return false
+ }
+ // userSettings 为 nil 或空 map 均视为成功
+ if r.UserSettings == nil || len(r.UserSettings) == 0 {
+ return true
+ }
+ // 如果包含 telemetryEnabled 字段,说明未成功清除
+ _, hasTelemetry := r.UserSettings["telemetryEnabled"]
+ return !hasTelemetry
+}
+
+// SetUserSettings 调用 setUserSettings API 设置用户隐私,返回解析后的响应
+func (c *Client) SetUserSettings(ctx context.Context, accessToken string) (*SetUserSettingsResponse, error) {
+ // 发送空 user_settings 以清除隐私设置
+ payload := SetUserSettingsRequest{UserSettings: map[string]any{}}
+ bodyBytes, err := json.Marshal(payload)
+ if err != nil {
+ return nil, fmt.Errorf("序列化请求失败: %w", err)
+ }
+
+ apiURL := privacyBaseURL + "/v1internal:setUserSettings"
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes))
+ if err != nil {
+ return nil, fmt.Errorf("创建请求失败: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "*/*")
+ req.Header.Set("User-Agent", GetUserAgent())
+ req.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1")
+ req.Host = "daily-cloudcode-pa.googleapis.com"
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("setUserSettings 请求失败: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("读取响应失败: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("setUserSettings 失败 (HTTP %d): %s", resp.StatusCode, string(respBody))
+ }
+
+ var result SetUserSettingsResponse
+ if err := json.Unmarshal(respBody, &result); err != nil {
+ return nil, fmt.Errorf("响应解析失败: %w", err)
+ }
+
+ return &result, nil
+}
+
+// FetchUserInfo 调用 fetchUserInfo API 获取用户隐私设置状态
+func (c *Client) FetchUserInfo(ctx context.Context, accessToken, projectID string) (*FetchUserInfoResponse, error) {
+ reqBody := FetchUserInfoRequest{Project: projectID}
+ bodyBytes, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("序列化请求失败: %w", err)
+ }
+
+ apiURL := privacyBaseURL + "/v1internal:fetchUserInfo"
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes))
+ if err != nil {
+ return nil, fmt.Errorf("创建请求失败: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "*/*")
+ req.Header.Set("User-Agent", GetUserAgent())
+ req.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1")
+ req.Host = "daily-cloudcode-pa.googleapis.com"
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("fetchUserInfo 请求失败: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("读取响应失败: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("fetchUserInfo 失败 (HTTP %d): %s", resp.StatusCode, string(respBody))
+ }
+
+ var result FetchUserInfoResponse
+ if err := json.Unmarshal(respBody, &result); err != nil {
+ return nil, fmt.Errorf("响应解析失败: %w", err)
+ }
+
+ return &result, nil
+}
diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go
index c4ddeab3..6fd239bb 100644
--- a/backend/internal/server/routes/admin.go
+++ b/backend/internal/server/routes/admin.go
@@ -257,6 +257,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts.POST("/:id/test", h.Admin.Account.Test)
accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState)
accounts.POST("/:id/refresh", h.Admin.Account.Refresh)
+ accounts.POST("/:id/set-privacy", h.Admin.Account.SetPrivacy)
accounts.POST("/:id/refresh-tier", h.Admin.Account.RefreshTier)
accounts.GET("/:id/stats", h.Admin.Account.GetStats)
accounts.POST("/:id/clear-error", h.Admin.Account.ClearError)
diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go
index ed85ee34..3ac6a144 100644
--- a/backend/internal/service/admin_service.go
+++ b/backend/internal/service/admin_service.go
@@ -65,6 +65,10 @@ type AdminService interface {
SetAccountError(ctx context.Context, id int64, errorMsg string) error
// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号 privacy_mode,未设置则尝试关闭训练数据共享并持久化。
EnsureOpenAIPrivacy(ctx context.Context, account *Account) string
+ // EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号 privacy_mode,未设置则调用 setUserSettings 并持久化。
+ EnsureAntigravityPrivacy(ctx context.Context, account *Account) string
+ // ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
+ ForceAntigravityPrivacy(ctx context.Context, account *Account) string
SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error)
BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error)
CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error
@@ -2661,3 +2665,79 @@ func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Acc
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode})
return mode
}
+
+// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。
+// 如果 Extra["privacy_mode"] 已存在(无论成功或失败),直接跳过。
+// 仅对从未设置过隐私的账号执行 setUserSettings + fetchUserInfo 流程。
+// 用户可通过前端 ForceAntigravityPrivacy(SetPrivacy 按钮)强制重新设置。
+func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account *Account) string {
+ if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
+ return ""
+ }
+ // 已设置过则跳过(无论成功或失败),用户可通过 Force 手动重试
+ if account.Extra != nil {
+ if existing, ok := account.Extra["privacy_mode"].(string); ok && existing != "" {
+ return existing
+ }
+ }
+
+ token, _ := account.Credentials["access_token"].(string)
+ if token == "" {
+ return ""
+ }
+
+ projectID, _ := account.Credentials["project_id"].(string)
+
+ var proxyURL string
+ if account.ProxyID != nil {
+ if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
+ proxyURL = p.URL()
+ }
+ }
+
+ mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
+ if mode == "" {
+ return ""
+ }
+
+ if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
+ logger.LegacyPrintf("service.admin", "update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
+ return mode
+ }
+ applyAntigravityPrivacyMode(account, mode)
+ return mode
+}
+
+// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
+func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account *Account) string {
+ if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
+ return ""
+ }
+
+ token, _ := account.Credentials["access_token"].(string)
+ if token == "" {
+ return ""
+ }
+
+ projectID, _ := account.Credentials["project_id"].(string)
+
+ var proxyURL string
+ if account.ProxyID != nil {
+ if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
+ proxyURL = p.URL()
+ }
+ }
+
+ mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
+ if mode == "" {
+ return ""
+ }
+
+ if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
+ logger.LegacyPrintf("service.admin", "force_update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
+ return mode
+ }
+ applyAntigravityPrivacyMode(account, mode)
+ return mode
+}
+
diff --git a/backend/internal/service/antigravity_privacy_service.go b/backend/internal/service/antigravity_privacy_service.go
new file mode 100644
index 00000000..50fe07f6
--- /dev/null
+++ b/backend/internal/service/antigravity_privacy_service.go
@@ -0,0 +1,81 @@
+package service
+
+import (
+ "context"
+ "log/slog"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
+)
+
+const (
+ AntigravityPrivacySet = "privacy_set"
+ AntigravityPrivacyFailed = "privacy_set_failed"
+)
+
+// setAntigravityPrivacy 调用 Antigravity API 设置隐私并验证结果。
+// 流程:
+// 1. setUserSettings 清空设置 → 检查返回值 {"userSettings":{}}
+// 2. fetchUserInfo 二次验证隐私是否已生效(需要 project_id)
+//
+// 返回 privacy_mode 值:"privacy_set" 成功,"privacy_set_failed" 失败,空串表示无法执行。
+func setAntigravityPrivacy(ctx context.Context, accessToken, projectID, proxyURL string) string {
+ if accessToken == "" {
+ return ""
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+
+ client, err := antigravity.NewClient(proxyURL)
+ if err != nil {
+ slog.Warn("antigravity_privacy_client_error", "error", err.Error())
+ return AntigravityPrivacyFailed
+ }
+
+ // 第 1 步:调用 setUserSettings,检查返回值
+ setResp, err := client.SetUserSettings(ctx, accessToken)
+ if err != nil {
+ slog.Warn("antigravity_privacy_set_failed", "error", err.Error())
+ return AntigravityPrivacyFailed
+ }
+ if !setResp.IsSuccess() {
+ slog.Warn("antigravity_privacy_set_response_not_empty",
+ "user_settings", setResp.UserSettings,
+ )
+ return AntigravityPrivacyFailed
+ }
+
+ // 第 2 步:调用 fetchUserInfo 二次验证隐私是否已生效
+ if strings.TrimSpace(projectID) == "" {
+ slog.Warn("antigravity_privacy_missing_project_id")
+ return AntigravityPrivacyFailed
+ }
+ userInfo, err := client.FetchUserInfo(ctx, accessToken, projectID)
+ if err != nil {
+ slog.Warn("antigravity_privacy_verify_failed", "error", err.Error())
+ return AntigravityPrivacyFailed
+ }
+ if !userInfo.IsPrivate() {
+ slog.Warn("antigravity_privacy_verify_not_private",
+ "user_settings", userInfo.UserSettings,
+ )
+ return AntigravityPrivacyFailed
+ }
+
+ slog.Info("antigravity_privacy_set_success")
+ return AntigravityPrivacySet
+}
+
+func applyAntigravityPrivacyMode(account *Account, mode string) {
+ if account == nil || strings.TrimSpace(mode) == "" {
+ return
+ }
+ extra := make(map[string]any, len(account.Extra)+1)
+ for k, v := range account.Extra {
+ extra[k] = v
+ }
+ extra["privacy_mode"] = mode
+ account.Extra = extra
+}
diff --git a/backend/internal/service/antigravity_privacy_service_test.go b/backend/internal/service/antigravity_privacy_service_test.go
new file mode 100644
index 00000000..e1e41334
--- /dev/null
+++ b/backend/internal/service/antigravity_privacy_service_test.go
@@ -0,0 +1,18 @@
+//go:build unit
+
+package service
+
+import "testing"
+
+func TestApplyAntigravityPrivacyMode_SetsInMemoryExtra(t *testing.T) {
+ account := &Account{}
+
+ applyAntigravityPrivacyMode(account, AntigravityPrivacySet)
+
+ if account.Extra == nil {
+ t.Fatal("expected account.Extra to be initialized")
+ }
+ if got := account.Extra["privacy_mode"]; got != AntigravityPrivacySet {
+ t.Fatalf("expected privacy_mode %q, got %v", AntigravityPrivacySet, got)
+ }
+}
diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go
index 24b7424f..f3d7ce58 100644
--- a/backend/internal/service/token_refresh_service.go
+++ b/backend/internal/service/token_refresh_service.go
@@ -128,7 +128,7 @@ func (s *TokenRefreshService) Start() {
)
}
-// Stop 停止刷新服务
+// Stop 停止刷新服务(可安全多次调用)
func (s *TokenRefreshService) Stop() {
close(s.stopCh)
s.wg.Wait()
@@ -404,6 +404,8 @@ func (s *TokenRefreshService) postRefreshActions(ctx context.Context, account *A
}
// OpenAI OAuth: 刷新成功后,检查是否已设置 privacy_mode,未设置则尝试关闭训练数据共享
s.ensureOpenAIPrivacy(ctx, account)
+ // Antigravity OAuth: 刷新成功后,检查是否已设置 privacy_mode,未设置则调用 setUserSettings
+ s.ensureAntigravityPrivacy(ctx, account)
}
// errRefreshSkipped 表示刷新被跳过(锁竞争或已被其他路径刷新),不计入 failed 或 refreshed
@@ -477,3 +479,51 @@ func (s *TokenRefreshService) ensureOpenAIPrivacy(ctx context.Context, account *
)
}
}
+
+// ensureAntigravityPrivacy 后台刷新中检查 Antigravity OAuth 账号隐私状态。
+// 仅做 Extra["privacy_mode"] 存在性检查,不发起 HTTP 请求,避免每轮循环产生额外网络开销。
+// 用户可通过前端 SetPrivacy 按钮强制重新设置。
+func (s *TokenRefreshService) ensureAntigravityPrivacy(ctx context.Context, account *Account) {
+ if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
+ return
+ }
+ // 已设置过(无论成功或失败)则跳过,不发 HTTP
+ if account.Extra != nil {
+ if _, ok := account.Extra["privacy_mode"]; ok {
+ return
+ }
+ }
+
+ token, _ := account.Credentials["access_token"].(string)
+ if token == "" {
+ return
+ }
+
+ projectID, _ := account.Credentials["project_id"].(string)
+
+ var proxyURL string
+ if account.ProxyID != nil && s.proxyRepo != nil {
+ if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
+ proxyURL = p.URL()
+ }
+ }
+
+ mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
+ if mode == "" {
+ return
+ }
+
+ if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
+ slog.Warn("token_refresh.update_antigravity_privacy_mode_failed",
+ "account_id", account.ID,
+ "error", err,
+ )
+ } else {
+ applyAntigravityPrivacyMode(account, mode)
+ slog.Info("token_refresh.antigravity_privacy_mode_set",
+ "account_id", account.ID,
+ "privacy_mode", mode,
+ )
+ }
+}
+
diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts
index ece5a30f..fd93fe7e 100644
--- a/frontend/src/api/admin/accounts.ts
+++ b/frontend/src/api/admin/accounts.ts
@@ -627,6 +627,16 @@ export async function batchRefresh(accountIds: number[]): Promise {
+ const { data } = await apiClient.post(`/admin/accounts/${id}/set-privacy`)
+ return data
+}
+
export const accountsAPI = {
list,
listWithEtag,
@@ -663,7 +673,8 @@ export const accountsAPI = {
importData,
getAntigravityDefaultModelMapping,
batchClearError,
- batchRefresh
+ batchRefresh,
+ setPrivacy
}
export default accountsAPI
diff --git a/frontend/src/components/admin/account/AccountActionMenu.vue b/frontend/src/components/admin/account/AccountActionMenu.vue
index f5bc5aa0..e682ddaf 100644
--- a/frontend/src/components/admin/account/AccountActionMenu.vue
+++ b/frontend/src/components/admin/account/AccountActionMenu.vue
@@ -32,6 +32,10 @@
{{ t('admin.accounts.refreshToken') }}
+
-
+
{{ planLabel }}
{
return 'Pro'
case 'free':
return 'Free'
+ case 'abnormal':
+ return t('admin.accounts.subscriptionAbnormal')
default:
return props.planType
}
@@ -139,6 +141,13 @@ const typeClass = computed(() => {
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
})
+const planBadgeClass = computed(() => {
+ if (props.planType && props.planType.toLowerCase() === 'abnormal') {
+ return 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400'
+ }
+ return typeClass.value
+})
+
// Privacy badge — shows different states for OpenAI/Antigravity OAuth privacy setting
const privacyBadge = computed(() => {
if (props.type !== 'oauth' || !props.privacyMode) return null
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index ab34945f..334de1f1 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -1987,6 +1987,7 @@ export default {
privacyAntigravitySet: 'Telemetry and marketing emails disabled',
privacyAntigravityFailed: 'Privacy setting failed',
setPrivacy: 'Set Privacy',
+ subscriptionAbnormal: 'Abnormal',
// Capacity status tooltips
capacity: {
windowCost: {
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 6120f94e..d296f436 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -2025,6 +2025,7 @@ export default {
privacyAntigravitySet: '已关闭遥测和营销邮件',
privacyAntigravityFailed: '隐私设置失败',
setPrivacy: '设置隐私',
+ subscriptionAbnormal: '异常',
// 容量状态提示
capacity: {
windowCost: {
From 975e6b15635a3284505457ddb1e2bda895d3abc4 Mon Sep 17 00:00:00 2001
From: QTom
Date: Wed, 25 Mar 2026 19:03:12 +0800
Subject: [PATCH 06/16] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20golangci-lint?=
=?UTF-8?q?=20=E6=8A=A5=E5=91=8A=E7=9A=84=205=20=E4=B8=AA=E9=97=AE?=
=?UTF-8?q?=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- gofmt: 修复 admin_service/antigravity_oauth_service/token_refresh_service 格式
- staticcheck S1009: 移除 SetUserSettingsResponse.IsSuccess 中冗余的 nil 检查
- unused: 将仅测试使用的 applyAntigravitySubscriptionResult 移至测试文件
Made-with: Cursor
---
backend/internal/pkg/antigravity/client.go | 2 +-
backend/internal/service/admin_service.go | 1 -
.../service/antigravity_oauth_service.go | 2 +-
.../antigravity_privacy_service_test.go | 28 ++++++++++++++++++-
.../antigravity_subscription_service.go | 24 ----------------
.../internal/service/token_refresh_service.go | 1 -
6 files changed, 29 insertions(+), 29 deletions(-)
diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go
index 880db797..fdd7fea1 100644
--- a/backend/internal/pkg/antigravity/client.go
+++ b/backend/internal/pkg/antigravity/client.go
@@ -767,7 +767,7 @@ func (r *SetUserSettingsResponse) IsSuccess() bool {
return false
}
// userSettings 为 nil 或空 map 均视为成功
- if r.UserSettings == nil || len(r.UserSettings) == 0 {
+ if len(r.UserSettings) == 0 {
return true
}
// 如果包含 telemetryEnabled 字段,说明未成功清除
diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go
index 3ac6a144..10f71bbc 100644
--- a/backend/internal/service/admin_service.go
+++ b/backend/internal/service/admin_service.go
@@ -2740,4 +2740,3 @@ func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account
applyAntigravityPrivacyMode(account, mode)
return mode
}
-
diff --git a/backend/internal/service/antigravity_oauth_service.go b/backend/internal/service/antigravity_oauth_service.go
index e0caae4c..a300d898 100644
--- a/backend/internal/service/antigravity_oauth_service.go
+++ b/backend/internal/service/antigravity_oauth_service.go
@@ -322,7 +322,7 @@ func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, accou
// loadCodeAssistResult 封装 loadProjectIDWithRetry 的返回结果,
// 同时携带从 LoadCodeAssist 响应中提取的 plan_type 信息。
type loadCodeAssistResult struct {
- ProjectID string
+ ProjectID string
Subscription *AntigravitySubscriptionResult
}
diff --git a/backend/internal/service/antigravity_privacy_service_test.go b/backend/internal/service/antigravity_privacy_service_test.go
index 11f05ab9..893500a6 100644
--- a/backend/internal/service/antigravity_privacy_service_test.go
+++ b/backend/internal/service/antigravity_privacy_service_test.go
@@ -2,7 +2,33 @@
package service
-import "testing"
+import (
+ "testing"
+)
+
+func applyAntigravitySubscriptionResult(account *Account, result AntigravitySubscriptionResult) (map[string]any, map[string]any) {
+ credentials := make(map[string]any)
+ for k, v := range account.Credentials {
+ credentials[k] = v
+ }
+ credentials["plan_type"] = result.PlanType
+
+ extra := make(map[string]any)
+ for k, v := range account.Extra {
+ extra[k] = v
+ }
+ if result.SubscriptionStatus != "" {
+ extra["subscription_status"] = result.SubscriptionStatus
+ } else {
+ delete(extra, "subscription_status")
+ }
+ if result.SubscriptionError != "" {
+ extra["subscription_error"] = result.SubscriptionError
+ } else {
+ delete(extra, "subscription_error")
+ }
+ return credentials, extra
+}
func TestApplyAntigravityPrivacyMode_SetsInMemoryExtra(t *testing.T) {
account := &Account{}
diff --git a/backend/internal/service/antigravity_subscription_service.go b/backend/internal/service/antigravity_subscription_service.go
index 46904427..04559be8 100644
--- a/backend/internal/service/antigravity_subscription_service.go
+++ b/backend/internal/service/antigravity_subscription_service.go
@@ -36,27 +36,3 @@ func NormalizeAntigravitySubscription(resp *antigravity.LoadCodeAssistResponse)
PlanType: antigravity.TierIDToPlanType(tierID),
}
}
-
-func applyAntigravitySubscriptionResult(account *Account, result AntigravitySubscriptionResult) (map[string]any, map[string]any) {
- credentials := make(map[string]any)
- for k, v := range account.Credentials {
- credentials[k] = v
- }
- credentials["plan_type"] = result.PlanType
-
- extra := make(map[string]any)
- for k, v := range account.Extra {
- extra[k] = v
- }
- if result.SubscriptionStatus != "" {
- extra["subscription_status"] = result.SubscriptionStatus
- } else {
- delete(extra, "subscription_status")
- }
- if result.SubscriptionError != "" {
- extra["subscription_error"] = result.SubscriptionError
- } else {
- delete(extra, "subscription_error")
- }
- return credentials, extra
-}
diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go
index f3d7ce58..ac14aa56 100644
--- a/backend/internal/service/token_refresh_service.go
+++ b/backend/internal/service/token_refresh_service.go
@@ -526,4 +526,3 @@ func (s *TokenRefreshService) ensureAntigravityPrivacy(ctx context.Context, acco
)
}
}
-
From 587557121536ad6ffa62b9ca5a255caebadb6add Mon Sep 17 00:00:00 2001
From: QTom
Date: Sat, 21 Mar 2026 18:45:00 +0800
Subject: [PATCH 07/16] =?UTF-8?q?fix(ratelimit):=20OpenAI=20401=20token=5F?=
=?UTF-8?q?invalidated/token=5Frevoked=20=E5=8F=8A=20402=20deactivated=5Fw?=
=?UTF-8?q?orkspace=20=E6=A0=87=E8=AE=B0=E8=B4=A6=E5=8F=B7=E5=BC=82?=
=?UTF-8?q?=E5=B8=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 401 token_invalidated / token_revoked: OAuth token 被永久作废,跳过临时不可调度逻辑,直接 SetError
- 402 deactivated_workspace: 解析 detail.code 字段,标记工作区已停用
Co-Authored-By: Claude Opus 4.6 (1M context)
---
backend/internal/service/ratelimit_service.go | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go
index afe5816d..aa0ae200 100644
--- a/backend/internal/service/ratelimit_service.go
+++ b/backend/internal/service/ratelimit_service.go
@@ -12,6 +12,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
+ "github.com/tidwall/gjson"
)
// RateLimitService 处理限流和过载状态管理
@@ -149,6 +150,17 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
}
// 其他 400 错误(如参数问题)不处理,不禁用账号
case 401:
+ // OpenAI: token_invalidated / token_revoked 表示 token 被永久作废(非过期),直接标记 error
+ openai401Code := extractUpstreamErrorCode(responseBody)
+ if account.Platform == PlatformOpenAI && (openai401Code == "token_invalidated" || openai401Code == "token_revoked") {
+ msg := "Token revoked (401): account authentication permanently revoked"
+ if upstreamMsg != "" {
+ msg = "Token revoked (401): " + upstreamMsg
+ }
+ s.handleAuthError(ctx, account, msg)
+ shouldDisable = true
+ break
+ }
// OAuth 账号在 401 错误时临时不可调度(给 token 刷新窗口);非 OAuth 账号保持原有 SetError 行为。
// Antigravity 除外:其 401 由 applyErrorPolicy 的 temp_unschedulable_rules 自行控制。
if account.Type == AccountTypeOAuth && account.Platform != PlatformAntigravity {
@@ -192,6 +204,13 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
shouldDisable = true
}
case 402:
+ // OpenAI: deactivated_workspace 表示工作区已停用,直接标记 error
+ if account.Platform == PlatformOpenAI && gjson.GetBytes(responseBody, "detail.code").String() == "deactivated_workspace" {
+ msg := "Workspace deactivated (402): workspace has been deactivated"
+ s.handleAuthError(ctx, account, msg)
+ shouldDisable = true
+ break
+ }
// 支付要求:余额不足或计费问题,停止调度
msg := "Payment required (402): insufficient balance or billing issue"
if upstreamMsg != "" {
From 7c6dc9dda88d3ac7549a29865f71adea6de781e1 Mon Sep 17 00:00:00 2001
From: Dave King
Date: Wed, 25 Mar 2026 12:19:17 +0000
Subject: [PATCH 08/16] fix: add account and proxy details to
gateway.forward_failed log
The forward_failed error log only included account_id, making it
difficult to identify which account and proxy caused the failure
without querying the database. Add account_name, account_platform,
and proxy details (id, name, host, port) to the log fields.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
backend/internal/handler/gateway_handler.go | 34 ++++++++++++++++++---
1 file changed, 30 insertions(+), 4 deletions(-)
diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go
index b9285c04..a0d8b2e9 100644
--- a/backend/internal/handler/gateway_handler.go
+++ b/backend/internal/handler/gateway_handler.go
@@ -422,11 +422,24 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
}
wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
- reqLog.Error("gateway.forward_failed",
+ forwardFailedFields := []zap.Field{
zap.Int64("account_id", account.ID),
+ zap.String("account_name", account.Name),
+ zap.String("account_platform", account.Platform),
zap.Bool("fallback_error_response_written", wroteFallback),
zap.Error(err),
- )
+ }
+ if account.Proxy != nil {
+ forwardFailedFields = append(forwardFailedFields,
+ zap.Int64("proxy_id", account.Proxy.ID),
+ zap.String("proxy_name", account.Proxy.Name),
+ zap.String("proxy_host", account.Proxy.Host),
+ zap.Int("proxy_port", account.Proxy.Port),
+ )
+ } else if account.ProxyID != nil {
+ forwardFailedFields = append(forwardFailedFields, zap.Int64p("proxy_id", account.ProxyID))
+ }
+ reqLog.Error("gateway.forward_failed", forwardFailedFields...)
return
}
@@ -741,11 +754,24 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
}
wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
- reqLog.Error("gateway.forward_failed",
+ forwardFailedFields := []zap.Field{
zap.Int64("account_id", account.ID),
+ zap.String("account_name", account.Name),
+ zap.String("account_platform", account.Platform),
zap.Bool("fallback_error_response_written", wroteFallback),
zap.Error(err),
- )
+ }
+ if account.Proxy != nil {
+ forwardFailedFields = append(forwardFailedFields,
+ zap.Int64("proxy_id", account.Proxy.ID),
+ zap.String("proxy_name", account.Proxy.Name),
+ zap.String("proxy_host", account.Proxy.Host),
+ zap.Int("proxy_port", account.Proxy.Port),
+ )
+ } else if account.ProxyID != nil {
+ forwardFailedFields = append(forwardFailedFields, zap.Int64p("proxy_id", account.ProxyID))
+ }
+ reqLog.Error("gateway.forward_failed", forwardFailedFields...)
return
}
From b20e142249fd752e2a5471d4cc21d3b4b90f4739 Mon Sep 17 00:00:00 2001
From: shaw
Date: Thu, 26 Mar 2026 10:22:03 +0800
Subject: [PATCH 09/16] =?UTF-8?q?feat:=20=E7=BD=91=E5=85=B3=E8=AF=B7?=
=?UTF-8?q?=E6=B1=82=E5=A4=B4=20wire=20casing=20=E4=BF=9D=E6=8C=81?=
=?UTF-8?q?=E3=80=81=E8=BD=AC=E5=8F=91=E8=A1=8C=E4=B8=BA=E5=BC=80=E5=85=B3?=
=?UTF-8?q?=E3=80=81=E8=B0=83=E8=AF=95=E6=97=A5=E5=BF=97=E5=A2=9E=E5=BC=BA?=
=?UTF-8?q?=E5=8F=8A=20accept-encoding=20=E6=81=A2=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 header_util.go,通过 setHeaderRaw/getHeaderRaw/addHeaderRaw 绕过
Go 的 canonical-case 规范化,保持真实 Claude CLI 抓包的请求头大小写
(如 "x-app" 而非 "X-App","X-Stainless-OS" 而非 "X-Stainless-Os")
- 新增管理后台开关:指纹统一化(默认开启)和 metadata 透传(默认关闭),
使用 atomic.Value + singleflight 缓存模式,60s TTL
- 调试日志从控制台 body 打印升级为文件级完整快照
(按真实 wire 顺序输出 headers + 格式化 JSON body + 上下文元数据)
- 恢复 accept-encoding 到白名单,在 http_upstream.go 新增 decompressResponseBody
处理 gzip/brotli/deflate 解压(Go 显式设置 Accept-Encoding 时不会自动解压)
- OAuth 服务 axios UA 从 1.8.4 更新至 1.13.6
- 测试断言改用 getHeaderRaw 适配 raw header 存储方式
---
.../internal/handler/admin/setting_handler.go | 26 ++
backend/internal/handler/dto/settings.go | 4 +
.../repository/claude_oauth_service.go | 4 +-
backend/internal/repository/http_upstream.go | 63 ++++
backend/internal/server/api_contract_test.go | 2 +
backend/internal/service/domain_constants.go | 6 +
...teway_anthropic_apikey_passthrough_test.go | 28 +-
.../service/gateway_debug_env_test.go | 23 +-
backend/internal/service/gateway_service.go | 322 +++++++++++-------
backend/internal/service/header_util.go | 157 +++++++++
backend/internal/service/identity_service.go | 17 +-
backend/internal/service/setting_service.go | 83 +++++
backend/internal/service/settings_view.go | 4 +
frontend/src/api/admin/settings.ts | 6 +
frontend/src/i18n/locales/en.ts | 8 +
frontend/src/i18n/locales/zh.ts | 8 +
frontend/src/views/admin/SettingsView.vue | 48 ++-
17 files changed, 655 insertions(+), 154 deletions(-)
create mode 100644 backend/internal/service/header_util.go
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index b5a7eb77..f57244fb 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -129,6 +129,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling,
BackendModeEnabled: settings.BackendModeEnabled,
+ EnableFingerprintUnification: settings.EnableFingerprintUnification,
+ EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
})
}
@@ -209,6 +211,10 @@ type UpdateSettingsRequest struct {
// Backend Mode
BackendModeEnabled bool `json:"backend_mode_enabled"`
+
+ // Gateway forwarding behavior
+ EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
+ EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
}
// UpdateSettings 更新系统设置
@@ -601,6 +607,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.OpsMetricsIntervalSeconds
}(),
+ EnableFingerprintUnification: func() bool {
+ if req.EnableFingerprintUnification != nil {
+ return *req.EnableFingerprintUnification
+ }
+ return previousSettings.EnableFingerprintUnification
+ }(),
+ EnableMetadataPassthrough: func() bool {
+ if req.EnableMetadataPassthrough != nil {
+ return *req.EnableMetadataPassthrough
+ }
+ return previousSettings.EnableMetadataPassthrough
+ }(),
}
if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil {
@@ -679,6 +697,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling,
BackendModeEnabled: updatedSettings.BackendModeEnabled,
+ EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
+ EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
})
}
@@ -851,6 +871,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.CustomMenuItems != after.CustomMenuItems {
changed = append(changed, "custom_menu_items")
}
+ if before.EnableFingerprintUnification != after.EnableFingerprintUnification {
+ changed = append(changed, "enable_fingerprint_unification")
+ }
+ if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough {
+ changed = append(changed, "enable_metadata_passthrough")
+ }
return changed
}
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index 7ea34aa0..59d7f688 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -94,6 +94,10 @@ type SystemSettings struct {
// Backend Mode
BackendModeEnabled bool `json:"backend_mode_enabled"`
+
+ // Gateway forwarding behavior
+ EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
+ EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
}
type DefaultSubscriptionSetting struct {
diff --git a/backend/internal/repository/claude_oauth_service.go b/backend/internal/repository/claude_oauth_service.go
index b754bd55..fee5c645 100644
--- a/backend/internal/repository/claude_oauth_service.go
+++ b/backend/internal/repository/claude_oauth_service.go
@@ -212,7 +212,7 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
SetContext(ctx).
SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Content-Type", "application/json").
- SetHeader("User-Agent", "axios/1.8.4").
+ SetHeader("User-Agent", "axios/1.13.6").
SetBody(reqBody).
SetSuccessResult(&tokenResp).
Post(s.tokenURL)
@@ -250,7 +250,7 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
SetContext(ctx).
SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Content-Type", "application/json").
- SetHeader("User-Agent", "axios/1.8.4").
+ SetHeader("User-Agent", "axios/1.13.6").
SetBody(reqBody).
SetSuccessResult(&tokenResp).
Post(s.tokenURL)
diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go
index a4674c1a..12523a91 100644
--- a/backend/internal/repository/http_upstream.go
+++ b/backend/internal/repository/http_upstream.go
@@ -1,6 +1,8 @@
package repository
import (
+ "compress/flate"
+ "compress/gzip"
"errors"
"fmt"
"io"
@@ -13,6 +15,8 @@ import (
"sync/atomic"
"time"
+ "github.com/andybalholm/brotli"
+
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
@@ -143,6 +147,9 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
return nil, err
}
+ // 如果上游返回了压缩内容,解压后再交给业务层
+ decompressResponseBody(resp)
+
// 包装响应体,在关闭时自动减少计数并更新时间戳
// 这确保了流式响应(如 SSE)在完全读取前不会被淘汰
resp.Body = wrapTrackedBody(resp.Body, func() {
@@ -218,6 +225,9 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
slog.Debug("tls_fingerprint_request_success", "account_id", accountID, "status", resp.StatusCode)
+ // 如果上游返回了压缩内容,解压后再交给业务层
+ decompressResponseBody(resp)
+
// 包装响应体,在关闭时自动减少计数并更新时间戳
resp.Body = wrapTrackedBody(resp.Body, func() {
atomic.AddInt64(&entry.inFlight, -1)
@@ -884,3 +894,56 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser {
}
return &trackedBody{ReadCloser: body, onClose: onClose}
}
+
+// decompressResponseBody 根据 Content-Encoding 解压响应体。
+// 当请求显式设置了 accept-encoding 时,Go 的 Transport 不会自动解压,需要手动处理。
+// 解压成功后会删除 Content-Encoding 和 Content-Length header(长度已不准确)。
+func decompressResponseBody(resp *http.Response) {
+ if resp == nil || resp.Body == nil {
+ return
+ }
+ ce := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding")))
+ if ce == "" {
+ return
+ }
+
+ var reader io.Reader
+ switch ce {
+ case "gzip":
+ gr, err := gzip.NewReader(resp.Body)
+ if err != nil {
+ return // 解压失败,保持原样
+ }
+ reader = gr
+ case "br":
+ reader = brotli.NewReader(resp.Body)
+ case "deflate":
+ reader = flate.NewReader(resp.Body)
+ default:
+ return
+ }
+
+ originalBody := resp.Body
+ resp.Body = &decompressedBody{reader: reader, closer: originalBody}
+ resp.Header.Del("Content-Encoding")
+ resp.Header.Del("Content-Length") // 解压后长度不确定
+ resp.ContentLength = -1
+}
+
+// decompressedBody 组合解压 reader 和原始 body 的 close。
+type decompressedBody struct {
+ reader io.Reader
+ closer io.Closer
+}
+
+func (d *decompressedBody) Read(p []byte) (int, error) {
+ return d.reader.Read(p)
+}
+
+func (d *decompressedBody) Close() error {
+ // 如果 reader 本身也是 Closer(如 gzip.Reader),先关闭它
+ if rc, ok := d.reader.(io.Closer); ok {
+ _ = rc.Close()
+ }
+ return d.closer.Close()
+}
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 880fe8a0..ac4e05de 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -540,6 +540,8 @@ func TestAPIContracts(t *testing.T) {
"max_claude_code_version": "",
"allow_ungrouped_key_scheduling": false,
"backend_mode_enabled": false,
+ "enable_fingerprint_unification": true,
+ "enable_metadata_passthrough": false,
"custom_menu_items": [],
"custom_endpoints": []
}
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index 4ae5a469..ecac0db0 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -235,6 +235,12 @@ const (
// SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录
SettingKeyBackendModeEnabled = "backend_mode_enabled"
+
+ // Gateway Forwarding Behavior
+ // SettingKeyEnableFingerprintUnification 是否统一 OAuth 账号的 X-Stainless-* 指纹头(默认 true)
+ SettingKeyEnableFingerprintUnification = "enable_fingerprint_unification"
+ // SettingKeyEnableMetadataPassthrough 是否透传客户端原始 metadata.user_id(默认 false)
+ SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough"
)
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go
index a01dd02a..f4e1b533 100644
--- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go
+++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go
@@ -175,13 +175,13 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamPreservesBodyAnd
require.Equal(t, "claude-3-haiku-20240307", gjson.GetBytes(upstream.lastBody, "model").String(), "透传模式应应用账号级模型映射")
- require.Equal(t, "upstream-anthropic-key", upstream.lastReq.Header.Get("x-api-key"))
- require.Empty(t, upstream.lastReq.Header.Get("authorization"))
- require.Empty(t, upstream.lastReq.Header.Get("x-goog-api-key"))
- require.Empty(t, upstream.lastReq.Header.Get("cookie"))
- require.Equal(t, "2023-06-01", upstream.lastReq.Header.Get("anthropic-version"))
- require.Equal(t, "interleaved-thinking-2025-05-14", upstream.lastReq.Header.Get("anthropic-beta"))
- require.Empty(t, upstream.lastReq.Header.Get("x-stainless-lang"), "API Key 透传不应注入 OAuth 指纹头")
+ require.Equal(t, "upstream-anthropic-key", getHeaderRaw(upstream.lastReq.Header, "x-api-key"))
+ require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "authorization"))
+ require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-goog-api-key"))
+ require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "cookie"))
+ require.Equal(t, "2023-06-01", getHeaderRaw(upstream.lastReq.Header, "anthropic-version"))
+ require.Equal(t, "interleaved-thinking-2025-05-14", getHeaderRaw(upstream.lastReq.Header, "anthropic-beta"))
+ require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-stainless-lang"), "API Key 透传不应注入 OAuth 指纹头")
require.Contains(t, rec.Body.String(), `"cached_tokens":7`)
require.NotContains(t, rec.Body.String(), `"cache_read_input_tokens":7`, "透传输出不应被网关改写")
@@ -257,9 +257,9 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardCountTokensPreservesBo
require.NoError(t, err)
require.Equal(t, "claude-3-opus-20240229", gjson.GetBytes(upstream.lastBody, "model").String(), "count_tokens 透传模式应应用账号级模型映射")
- require.Equal(t, "upstream-anthropic-key", upstream.lastReq.Header.Get("x-api-key"))
- require.Empty(t, upstream.lastReq.Header.Get("authorization"))
- require.Empty(t, upstream.lastReq.Header.Get("cookie"))
+ require.Equal(t, "upstream-anthropic-key", getHeaderRaw(upstream.lastReq.Header, "x-api-key"))
+ require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "authorization"))
+ require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "cookie"))
require.Equal(t, http.StatusOK, rec.Code)
require.JSONEq(t, upstreamRespBody, rec.Body.String())
require.Empty(t, rec.Header().Get("Set-Cookie"))
@@ -684,8 +684,8 @@ func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *t
req, err := svc.buildUpstreamRequest(context.Background(), c, account, []byte(`{"model":"claude-3-7-sonnet-20250219"}`), "oauth-token", "oauth", "claude-3-7-sonnet-20250219", true, false)
require.NoError(t, err)
- require.Equal(t, "Bearer oauth-token", req.Header.Get("authorization"))
- require.Contains(t, req.Header.Get("anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta")
+ require.Equal(t, "Bearer oauth-token", getHeaderRaw(req.Header, "authorization"))
+ require.Contains(t, getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta")
}
func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(t *testing.T) {
@@ -755,8 +755,8 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, upstream.lastReq)
- require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("authorization"))
- require.Contains(t, upstream.lastReq.Header.Get("anthropic-beta"), claude.BetaOAuth)
+ require.Equal(t, "Bearer oauth-token", getHeaderRaw(upstream.lastReq.Header, "authorization"))
+ require.Contains(t, getHeaderRaw(upstream.lastReq.Header, "anthropic-beta"), claude.BetaOAuth)
system := gjson.GetBytes(upstream.lastBody, "system")
require.True(t, system.Exists())
diff --git a/backend/internal/service/gateway_debug_env_test.go b/backend/internal/service/gateway_debug_env_test.go
index 4f48dc70..bd88a667 100644
--- a/backend/internal/service/gateway_debug_env_test.go
+++ b/backend/internal/service/gateway_debug_env_test.go
@@ -2,31 +2,28 @@ package service
import "testing"
-func TestDebugGatewayBodyLoggingEnabled(t *testing.T) {
- t.Run("default disabled", func(t *testing.T) {
- t.Setenv(debugGatewayBodyEnv, "")
- if debugGatewayBodyLoggingEnabled() {
- t.Fatalf("expected debug gateway body logging to be disabled by default")
+func TestParseDebugEnvBool(t *testing.T) {
+ t.Run("empty is false", func(t *testing.T) {
+ if parseDebugEnvBool("") {
+ t.Fatalf("expected false for empty string")
}
})
- t.Run("enabled with true-like values", func(t *testing.T) {
+ t.Run("true-like values", func(t *testing.T) {
for _, value := range []string{"1", "true", "TRUE", "yes", "on"} {
t.Run(value, func(t *testing.T) {
- t.Setenv(debugGatewayBodyEnv, value)
- if !debugGatewayBodyLoggingEnabled() {
- t.Fatalf("expected debug gateway body logging to be enabled for %q", value)
+ if !parseDebugEnvBool(value) {
+ t.Fatalf("expected true for %q", value)
}
})
}
})
- t.Run("disabled with other values", func(t *testing.T) {
+ t.Run("false-like values", func(t *testing.T) {
for _, value := range []string{"0", "false", "off", "debug"} {
t.Run(value, func(t *testing.T) {
- t.Setenv(debugGatewayBodyEnv, value)
- if debugGatewayBodyLoggingEnabled() {
- t.Fatalf("expected debug gateway body logging to be disabled for %q", value)
+ if parseDebugEnvBool(value) {
+ t.Fatalf("expected false for %q", value)
}
})
}
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index 402975d7..ae66ae4a 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -13,6 +13,7 @@ import (
mathrand "math/rand"
"net/http"
"os"
+ "path/filepath"
"regexp"
"sort"
"strconv"
@@ -366,6 +367,7 @@ var allowedHeaders = map[string]bool{
"sec-fetch-mode": true,
"user-agent": true,
"content-type": true,
+ "accept-encoding": true,
}
// GatewayCache 定义网关服务的缓存操作接口。
@@ -563,6 +565,7 @@ type GatewayService struct {
responseHeaderFilter *responseheaders.CompiledHeaderFilter
debugModelRouting atomic.Bool
debugClaudeMimic atomic.Bool
+ debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
}
// NewGatewayService creates a new GatewayService
@@ -630,6 +633,9 @@ func NewGatewayService(
)
svc.debugModelRouting.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_MODEL_ROUTING")))
svc.debugClaudeMimic.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_CLAUDE_MIMIC")))
+ if path := strings.TrimSpace(os.Getenv(debugGatewayBodyEnv)); path != "" {
+ svc.initDebugGatewayBodyFile(path)
+ }
return svc
}
@@ -4048,8 +4054,15 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
reqStream := parsed.Stream
originalModel := reqModel
- // === DEBUG: 打印客户端原始请求 body ===
- debugLogRequestBody("CLIENT_ORIGINAL", body)
+ // === DEBUG: 打印客户端原始请求(headers + body 摘要)===
+ if c != nil {
+ s.debugLogGatewaySnapshot("CLIENT_ORIGINAL", c.Request.Header, body, map[string]string{
+ "account": fmt.Sprintf("%d(%s)", account.ID, account.Name),
+ "account_type": string(account.Type),
+ "model": reqModel,
+ "stream": strconv.FormatBool(reqStream),
+ })
+ }
isClaudeCode := isClaudeCodeRequest(ctx, c, parsed)
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
@@ -4066,9 +4079,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
if s.identityService != nil {
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
if err == nil && fp != nil {
- if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
- normalizeOpts.injectMetadata = true
- normalizeOpts.metadataUserID = metadataUserID
+ // metadata 透传开启时跳过 metadata 注入
+ _, mimicMPT := s.settingService.GetGatewayForwardingSettings(ctx)
+ if !mimicMPT {
+ if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
+ normalizeOpts.injectMetadata = true
+ normalizeOpts.metadataUserID = metadataUserID
+ }
}
}
}
@@ -4840,8 +4857,9 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
if !allowedHeaders[lowerKey] {
continue
}
+ wireKey := resolveWireCasing(key)
for _, v := range values {
- req.Header.Add(key, v)
+ addHeaderRaw(req.Header, wireKey, v)
}
}
}
@@ -4851,13 +4869,13 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
req.Header.Del("x-api-key")
req.Header.Del("x-goog-api-key")
req.Header.Del("cookie")
- req.Header.Set("x-api-key", token)
+ setHeaderRaw(req.Header, "x-api-key", token)
- if req.Header.Get("content-type") == "" {
- req.Header.Set("content-type", "application/json")
+ if getHeaderRaw(req.Header, "content-type") == "" {
+ setHeaderRaw(req.Header, "content-type", "application/json")
}
- if req.Header.Get("anthropic-version") == "" {
- req.Header.Set("anthropic-version", "2023-06-01")
+ if getHeaderRaw(req.Header, "anthropic-version") == "" {
+ setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
}
return req, nil
@@ -5591,8 +5609,12 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
clientHeaders = c.Request.Header
}
- // OAuth账号:应用统一指纹
+ // OAuth账号:应用统一指纹和metadata重写(受设置开关控制)
var fingerprint *Fingerprint
+ enableFP, enableMPT := true, false
+ if s.settingService != nil {
+ enableFP, enableMPT = s.settingService.GetGatewayForwardingSettings(ctx)
+ }
if account.IsOAuth() && s.identityService != nil {
// 1. 获取或创建指纹(包含随机生成的ClientID)
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
@@ -5600,40 +5622,43 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err)
// 失败时降级为透传原始headers
} else {
- fingerprint = fp
+ if enableFP {
+ fingerprint = fp
+ }
// 2. 重写metadata.user_id(需要指纹中的ClientID和账号的account_uuid)
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
- accountUUID := account.GetExtraString("account_uuid")
- if accountUUID != "" && fp.ClientID != "" {
- if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
- body = newBody
+ // 当 metadata 透传开启时跳过重写
+ if !enableMPT {
+ accountUUID := account.GetExtraString("account_uuid")
+ if accountUUID != "" && fp.ClientID != "" {
+ if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
+ body = newBody
+ }
}
}
}
}
- // === DEBUG: 打印转发给上游的 body(metadata 已重写) ===
- debugLogRequestBody("UPSTREAM_FORWARD", body)
-
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
- // 设置认证头
+ // 设置认证头(保持原始大小写)
if tokenType == "oauth" {
- req.Header.Set("authorization", "Bearer "+token)
+ setHeaderRaw(req.Header, "authorization", "Bearer "+token)
} else {
- req.Header.Set("x-api-key", token)
+ setHeaderRaw(req.Header, "x-api-key", token)
}
- // 白名单透传headers
+ // 白名单透传headers(恢复真实 wire casing)
for key, values := range clientHeaders {
lowerKey := strings.ToLower(key)
if allowedHeaders[lowerKey] {
+ wireKey := resolveWireCasing(key)
for _, v := range values {
- req.Header.Add(key, v)
+ addHeaderRaw(req.Header, wireKey, v)
}
}
}
@@ -5643,15 +5668,15 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
s.identityService.ApplyFingerprint(req, fingerprint)
}
- // 确保必要的headers存在
- if req.Header.Get("content-type") == "" {
- req.Header.Set("content-type", "application/json")
+ // 确保必要的headers存在(保持原始大小写)
+ if getHeaderRaw(req.Header, "content-type") == "" {
+ setHeaderRaw(req.Header, "content-type", "application/json")
}
- if req.Header.Get("anthropic-version") == "" {
- req.Header.Set("anthropic-version", "2023-06-01")
+ if getHeaderRaw(req.Header, "anthropic-version") == "" {
+ setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
}
if tokenType == "oauth" {
- applyClaudeOAuthHeaderDefaults(req, reqStream)
+ applyClaudeOAuthHeaderDefaults(req)
}
// Build effective drop set: merge static defaults with dynamic beta policy filter rules
@@ -5667,31 +5692,41 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
// - 保留 incoming beta 的同时,确保 OAuth 所需 beta 存在
applyClaudeCodeMimicHeaders(req, reqStream)
- incomingBeta := req.Header.Get("anthropic-beta")
+ incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
// Match real Claude CLI traffic (per mitmproxy reports):
// messages requests typically use only oauth + interleaved-thinking.
// Also drop claude-code beta if a downstream client added it.
requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking}
- req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropWithClaudeCodeSet))
+ setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropWithClaudeCodeSet))
} else {
// Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta
- clientBetaHeader := req.Header.Get("anthropic-beta")
- req.Header.Set("anthropic-beta", stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBetaHeader), effectiveDropSet))
+ clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
+ setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBetaHeader), effectiveDropSet))
}
} else {
// API-key accounts: apply beta policy filter to strip controlled tokens
- if existingBeta := req.Header.Get("anthropic-beta"); existingBeta != "" {
- req.Header.Set("anthropic-beta", stripBetaTokensWithSet(existingBeta, effectiveDropSet))
+ if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" {
+ setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, effectiveDropSet))
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
// API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭)
if requestNeedsBetaFeatures(body) {
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
- req.Header.Set("anthropic-beta", beta)
+ setHeaderRaw(req.Header, "anthropic-beta", beta)
}
}
}
}
+ // === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 ===
+ s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{
+ "url": req.URL.String(),
+ "token_type": tokenType,
+ "mimic_claude_code": strconv.FormatBool(mimicClaudeCode),
+ "fingerprint_applied": strconv.FormatBool(fingerprint != nil),
+ "enable_fp": strconv.FormatBool(enableFP),
+ "enable_mpt": strconv.FormatBool(enableMPT),
+ })
+
// Always capture a compact fingerprint line for later error diagnostics.
// We only print it when needed (or when the explicit debug flag is enabled).
if c != nil && tokenType == "oauth" {
@@ -5771,24 +5806,21 @@ func defaultAPIKeyBetaHeader(body []byte) string {
return claude.APIKeyBetaHeader
}
-func applyClaudeOAuthHeaderDefaults(req *http.Request, isStream bool) {
+func applyClaudeOAuthHeaderDefaults(req *http.Request) {
if req == nil {
return
}
- if req.Header.Get("accept") == "" {
- req.Header.Set("accept", "application/json")
+ if getHeaderRaw(req.Header, "Accept") == "" {
+ setHeaderRaw(req.Header, "Accept", "application/json")
}
for key, value := range claude.DefaultHeaders {
if value == "" {
continue
}
- if req.Header.Get(key) == "" {
- req.Header.Set(key, value)
+ if getHeaderRaw(req.Header, key) == "" {
+ setHeaderRaw(req.Header, resolveWireCasing(key), value)
}
}
- if isStream && req.Header.Get("x-stainless-helper-method") == "" {
- req.Header.Set("x-stainless-helper-method", "stream")
- }
}
func mergeAnthropicBeta(required []string, incoming string) string {
@@ -6083,18 +6115,19 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
return
}
// Start with the standard defaults (fill missing).
- applyClaudeOAuthHeaderDefaults(req, isStream)
+ applyClaudeOAuthHeaderDefaults(req)
// Then force key headers to match Claude Code fingerprint regardless of what the client sent.
+ // 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App")
for key, value := range claude.DefaultHeaders {
if value == "" {
continue
}
- req.Header.Set(key, value)
+ setHeaderRaw(req.Header, resolveWireCasing(key), value)
}
// Real Claude CLI uses Accept: application/json (even for streaming).
- req.Header.Set("accept", "application/json")
+ setHeaderRaw(req.Header, "Accept", "application/json")
if isStream {
- req.Header.Set("x-stainless-helper-method", "stream")
+ setHeaderRaw(req.Header, "x-stainless-helper-method", "stream")
}
}
@@ -8197,8 +8230,9 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
if !allowedHeaders[lowerKey] {
continue
}
+ wireKey := resolveWireCasing(key)
for _, v := range values {
- req.Header.Add(key, v)
+ addHeaderRaw(req.Header, wireKey, v)
}
}
}
@@ -8239,15 +8273,23 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
clientHeaders = c.Request.Header
}
- // OAuth 账号:应用统一指纹和重写 userID
+ // OAuth 账号:应用统一指纹和重写 userID(受设置开关控制)
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
+ ctEnableFP, ctEnableMPT := true, false
+ if s.settingService != nil {
+ ctEnableFP, ctEnableMPT = s.settingService.GetGatewayForwardingSettings(ctx)
+ }
+ var ctFingerprint *Fingerprint
if account.IsOAuth() && s.identityService != nil {
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
if err == nil {
- accountUUID := account.GetExtraString("account_uuid")
- if accountUUID != "" && fp.ClientID != "" {
- if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
- body = newBody
+ ctFingerprint = fp
+ if !ctEnableMPT {
+ accountUUID := account.GetExtraString("account_uuid")
+ if accountUUID != "" && fp.ClientID != "" {
+ if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
+ body = newBody
+ }
}
}
}
@@ -8258,40 +8300,38 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
return nil, err
}
- // 设置认证头
+ // 设置认证头(保持原始大小写)
if tokenType == "oauth" {
- req.Header.Set("authorization", "Bearer "+token)
+ setHeaderRaw(req.Header, "authorization", "Bearer "+token)
} else {
- req.Header.Set("x-api-key", token)
+ setHeaderRaw(req.Header, "x-api-key", token)
}
- // 白名单透传 headers
+ // 白名单透传 headers(恢复真实 wire casing)
for key, values := range clientHeaders {
lowerKey := strings.ToLower(key)
if allowedHeaders[lowerKey] {
+ wireKey := resolveWireCasing(key)
for _, v := range values {
- req.Header.Add(key, v)
+ addHeaderRaw(req.Header, wireKey, v)
}
}
}
- // OAuth 账号:应用指纹到请求头
- if account.IsOAuth() && s.identityService != nil {
- fp, _ := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
- if fp != nil {
- s.identityService.ApplyFingerprint(req, fp)
- }
+ // OAuth 账号:应用指纹到请求头(受设置开关控制)
+ if ctEnableFP && ctFingerprint != nil {
+ s.identityService.ApplyFingerprint(req, ctFingerprint)
}
- // 确保必要的 headers 存在
- if req.Header.Get("content-type") == "" {
- req.Header.Set("content-type", "application/json")
+ // 确保必要的 headers 存在(保持原始大小写)
+ if getHeaderRaw(req.Header, "content-type") == "" {
+ setHeaderRaw(req.Header, "content-type", "application/json")
}
- if req.Header.Get("anthropic-version") == "" {
- req.Header.Set("anthropic-version", "2023-06-01")
+ if getHeaderRaw(req.Header, "anthropic-version") == "" {
+ setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
}
if tokenType == "oauth" {
- applyClaudeOAuthHeaderDefaults(req, false)
+ applyClaudeOAuthHeaderDefaults(req)
}
// Build effective drop set for count_tokens: merge static defaults with dynamic beta policy filter rules
@@ -8302,30 +8342,30 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
if mimicClaudeCode {
applyClaudeCodeMimicHeaders(req, false)
- incomingBeta := req.Header.Get("anthropic-beta")
+ incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
requiredBetas := []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking, claude.BetaTokenCounting}
- req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, ctEffectiveDropSet))
+ setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, ctEffectiveDropSet))
} else {
- clientBetaHeader := req.Header.Get("anthropic-beta")
+ clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
if clientBetaHeader == "" {
- req.Header.Set("anthropic-beta", claude.CountTokensBetaHeader)
+ setHeaderRaw(req.Header, "anthropic-beta", claude.CountTokensBetaHeader)
} else {
beta := s.getBetaHeader(modelID, clientBetaHeader)
if !strings.Contains(beta, claude.BetaTokenCounting) {
beta = beta + "," + claude.BetaTokenCounting
}
- req.Header.Set("anthropic-beta", stripBetaTokensWithSet(beta, ctEffectiveDropSet))
+ setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(beta, ctEffectiveDropSet))
}
}
} else {
// API-key accounts: apply beta policy filter to strip controlled tokens
- if existingBeta := req.Header.Get("anthropic-beta"); existingBeta != "" {
- req.Header.Set("anthropic-beta", stripBetaTokensWithSet(existingBeta, ctEffectiveDropSet))
+ if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" {
+ setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, ctEffectiveDropSet))
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
// API-key:与 messages 同步的按需 beta 注入(默认关闭)
if requestNeedsBetaFeatures(body) {
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
- req.Header.Set("anthropic-beta", beta)
+ setHeaderRaw(req.Header, "anthropic-beta", beta)
}
}
}
@@ -8496,42 +8536,94 @@ func reconcileCachedTokens(usage map[string]any) bool {
return true
}
-func debugGatewayBodyLoggingEnabled() bool {
- raw := strings.TrimSpace(os.Getenv(debugGatewayBodyEnv))
- if raw == "" {
- return false
- }
+const debugGatewayBodyDefaultFilename = "gateway_debug.log"
- switch strings.ToLower(raw) {
- case "1", "true", "yes", "on":
- return true
- default:
- return false
- }
-}
-
-// debugLogRequestBody 打印请求 body 用于调试 metadata.user_id 重写。
-// 默认关闭,仅在设置环境变量时启用:
+// initDebugGatewayBodyFile 初始化网关调试日志文件。
//
-// SUB2API_DEBUG_GATEWAY_BODY=1
-func debugLogRequestBody(tag string, body []byte) {
- if !debugGatewayBodyLoggingEnabled() {
+// - "1"/"true" 等布尔值 → 当前目录下 gateway_debug.log
+// - 已有目录路径 → 该目录下 gateway_debug.log
+// - 其他 → 视为完整文件路径
+func (s *GatewayService) initDebugGatewayBodyFile(path string) {
+ if parseDebugEnvBool(path) {
+ path = debugGatewayBodyDefaultFilename
+ }
+
+ // 如果 path 指向一个已存在的目录,自动追加默认文件名
+ if info, err := os.Stat(path); err == nil && info.IsDir() {
+ path = filepath.Join(path, debugGatewayBodyDefaultFilename)
+ }
+
+ // 确保父目录存在
+ if dir := filepath.Dir(path); dir != "." {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ slog.Error("failed to create gateway debug log directory", "dir", dir, "error", err)
+ return
+ }
+ }
+
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
+ if err != nil {
+ slog.Error("failed to open gateway debug log file", "path", path, "error", err)
return
}
-
- if len(body) == 0 {
- logger.LegacyPrintf("service.gateway", "[DEBUG_%s] body is empty", tag)
- return
- }
-
- // 提取 metadata 字段完整打印
- metadataResult := gjson.GetBytes(body, "metadata")
- if metadataResult.Exists() {
- logger.LegacyPrintf("service.gateway", "[DEBUG_%s] metadata = %s", tag, metadataResult.Raw)
- } else {
- logger.LegacyPrintf("service.gateway", "[DEBUG_%s] metadata field not found", tag)
- }
-
- // 全量打印 body
- logger.LegacyPrintf("service.gateway", "[DEBUG_%s] body (%d bytes) = %s", tag, len(body), string(body))
+ s.debugGatewayBodyFile.Store(f)
+ slog.Info("gateway debug logging enabled", "path", path)
+}
+
+// debugLogGatewaySnapshot 将网关请求的完整快照(headers + body)写入独立的调试日志文件,
+// 用于对比客户端原始请求和上游转发请求。
+//
+// 启用方式(环境变量):
+//
+// SUB2API_DEBUG_GATEWAY_BODY=1 # 写入 gateway_debug.log
+// SUB2API_DEBUG_GATEWAY_BODY=/tmp/gateway_debug.log # 写入指定路径
+//
+// tag: "CLIENT_ORIGINAL" 或 "UPSTREAM_FORWARD"
+func (s *GatewayService) debugLogGatewaySnapshot(tag string, headers http.Header, body []byte, extra map[string]string) {
+ f := s.debugGatewayBodyFile.Load()
+ if f == nil {
+ return
+ }
+
+ var buf strings.Builder
+ ts := time.Now().Format("2006-01-02 15:04:05.000")
+ fmt.Fprintf(&buf, "\n========== [%s] %s ==========\n", ts, tag)
+
+ // 1. context
+ if len(extra) > 0 {
+ fmt.Fprint(&buf, "--- context ---\n")
+ extraKeys := make([]string, 0, len(extra))
+ for k := range extra {
+ extraKeys = append(extraKeys, k)
+ }
+ sort.Strings(extraKeys)
+ for _, k := range extraKeys {
+ fmt.Fprintf(&buf, " %s: %s\n", k, extra[k])
+ }
+ }
+
+ // 2. headers(按真实 Claude CLI wire 顺序排列,便于与抓包对比;auth 脱敏)
+ fmt.Fprint(&buf, "--- headers ---\n")
+ for _, k := range sortHeadersByWireOrder(headers) {
+ for _, v := range headers[k] {
+ fmt.Fprintf(&buf, " %s: %s\n", k, safeHeaderValueForLog(k, v))
+ }
+ }
+
+ // 3. body(完整输出,格式化 JSON 便于 diff)
+ fmt.Fprint(&buf, "--- body ---\n")
+ if len(body) == 0 {
+ fmt.Fprint(&buf, " (empty)\n")
+ } else {
+ var pretty bytes.Buffer
+ if json.Indent(&pretty, body, " ", " ") == nil {
+ fmt.Fprintf(&buf, " %s\n", pretty.Bytes())
+ } else {
+ // JSON 格式化失败时原样输出
+ fmt.Fprintf(&buf, " %s\n", body)
+ }
+ }
+
+ // 写入文件(调试用,并发写入可能交错但不影响可读性)
+ _, _ = f.WriteString(buf.String())
}
diff --git a/backend/internal/service/header_util.go b/backend/internal/service/header_util.go
new file mode 100644
index 00000000..6acfee5a
--- /dev/null
+++ b/backend/internal/service/header_util.go
@@ -0,0 +1,157 @@
+package service
+
+import (
+ "net/http"
+ "strings"
+)
+
+// headerWireCasing 定义每个白名单 header 在真实 Claude CLI 抓包中的准确大小写。
+// Go 的 HTTP server 解析请求时会将所有 header key 转为 Canonical 形式(如 x-app → X-App),
+// 此 map 用于在转发时恢复到真实的 wire format。
+//
+// 来源:对真实 Claude CLI (claude-cli/2.1.81) 到 api.anthropic.com 的 HTTPS 流量抓包。
+var headerWireCasing = map[string]string{
+ // Title case
+ "accept": "Accept",
+ "user-agent": "User-Agent",
+
+ // X-Stainless-* 保持 SDK 原始大小写
+ "x-stainless-retry-count": "X-Stainless-Retry-Count",
+ "x-stainless-timeout": "X-Stainless-Timeout",
+ "x-stainless-lang": "X-Stainless-Lang",
+ "x-stainless-package-version": "X-Stainless-Package-Version",
+ "x-stainless-os": "X-Stainless-OS",
+ "x-stainless-arch": "X-Stainless-Arch",
+ "x-stainless-runtime": "X-Stainless-Runtime",
+ "x-stainless-runtime-version": "X-Stainless-Runtime-Version",
+ "x-stainless-helper-method": "x-stainless-helper-method",
+
+ // Anthropic SDK 自身设置的 header,全小写
+ "anthropic-dangerous-direct-browser-access": "anthropic-dangerous-direct-browser-access",
+ "anthropic-version": "anthropic-version",
+ "anthropic-beta": "anthropic-beta",
+ "x-app": "x-app",
+ "content-type": "content-type",
+ "accept-language": "accept-language",
+ "sec-fetch-mode": "sec-fetch-mode",
+ "accept-encoding": "accept-encoding",
+ "authorization": "authorization",
+}
+
+// headerWireOrder 定义真实 Claude CLI 发送 header 的顺序(基于抓包)。
+// 用于 debug log 按此顺序输出,便于与抓包结果直接对比。
+var headerWireOrder = []string{
+ "Accept",
+ "X-Stainless-Retry-Count",
+ "X-Stainless-Timeout",
+ "X-Stainless-Lang",
+ "X-Stainless-Package-Version",
+ "X-Stainless-OS",
+ "X-Stainless-Arch",
+ "X-Stainless-Runtime",
+ "X-Stainless-Runtime-Version",
+ "anthropic-dangerous-direct-browser-access",
+ "anthropic-version",
+ "authorization",
+ "x-app",
+ "User-Agent",
+ "content-type",
+ "anthropic-beta",
+ "accept-language",
+ "sec-fetch-mode",
+ "accept-encoding",
+ "x-stainless-helper-method",
+}
+
+// headerWireOrderSet 用于快速判断某个 key 是否在 headerWireOrder 中(按 lowercase 匹配)。
+var headerWireOrderSet map[string]struct{}
+
+func init() {
+ headerWireOrderSet = make(map[string]struct{}, len(headerWireOrder))
+ for _, k := range headerWireOrder {
+ headerWireOrderSet[strings.ToLower(k)] = struct{}{}
+ }
+}
+
+// resolveWireCasing 将 Go canonical key(如 X-Stainless-Os)映射为真实 wire casing(如 X-Stainless-OS)。
+// 如果 map 中没有对应条目,返回原始 key 不变。
+func resolveWireCasing(key string) string {
+ if wk, ok := headerWireCasing[strings.ToLower(key)]; ok {
+ return wk
+ }
+ return key
+}
+
+// setHeaderRaw sets a header bypassing Go's canonical-case normalization.
+// The key is stored exactly as provided, preserving original casing.
+//
+// It first removes any existing value under the canonical key, the wire casing key,
+// and the exact raw key, preventing duplicates from any source.
+func setHeaderRaw(h http.Header, key, value string) {
+ h.Del(key) // remove canonical form (e.g. "Anthropic-Beta")
+ if wk := resolveWireCasing(key); wk != key {
+ delete(h, wk) // remove wire casing form if different
+ }
+ delete(h, key) // remove exact raw key if it differs from canonical
+ h[key] = []string{value}
+}
+
+// addHeaderRaw appends a header value bypassing Go's canonical-case normalization.
+func addHeaderRaw(h http.Header, key, value string) {
+ h[key] = append(h[key], value)
+}
+
+// getHeaderRaw reads a header value, trying multiple key forms to handle the mismatch
+// between Go canonical keys, wire casing keys, and raw keys:
+// 1. exact key as provided
+// 2. wire casing form (from headerWireCasing)
+// 3. Go canonical form (via http.Header.Get)
+func getHeaderRaw(h http.Header, key string) string {
+ // 1. exact key
+ if vals := h[key]; len(vals) > 0 {
+ return vals[0]
+ }
+ // 2. wire casing (e.g. looking up "Anthropic-Dangerous-Direct-Browser-Access" finds "anthropic-dangerous-direct-browser-access")
+ if wk := resolveWireCasing(key); wk != key {
+ if vals := h[wk]; len(vals) > 0 {
+ return vals[0]
+ }
+ }
+ // 3. canonical fallback
+ return h.Get(key)
+}
+
+// sortHeadersByWireOrder 按照真实 Claude CLI 的 header 顺序返回排序后的 key 列表。
+// 在 headerWireOrder 中定义的 key 按其顺序排列,未定义的 key 追加到末尾。
+func sortHeadersByWireOrder(h http.Header) []string {
+ // 构建 lowercase -> actual map key 的映射
+ present := make(map[string]string, len(h))
+ for k := range h {
+ present[strings.ToLower(k)] = k
+ }
+
+ result := make([]string, 0, len(h))
+ seen := make(map[string]struct{}, len(h))
+
+ // 先按 wire order 输出
+ for _, wk := range headerWireOrder {
+ lk := strings.ToLower(wk)
+ if actual, ok := present[lk]; ok {
+ if _, dup := seen[lk]; !dup {
+ result = append(result, actual)
+ seen[lk] = struct{}{}
+ }
+ }
+ }
+
+ // 再追加不在 wire order 中的 header
+ for k := range h {
+ lk := strings.ToLower(k)
+ if _, ok := seen[lk]; !ok {
+ result = append(result, k)
+ seen[lk] = struct{}{}
+ }
+ }
+
+ return result
+}
diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go
index 428f5bfd..3d706508 100644
--- a/backend/internal/service/identity_service.go
+++ b/backend/internal/service/identity_service.go
@@ -174,6 +174,7 @@ func getHeaderOrDefault(headers http.Header, key, defaultValue string) string {
}
// ApplyFingerprint 将指纹应用到请求头(覆盖原有的x-stainless-*头)
+// 使用 setHeaderRaw 保持原始大小写(如 X-Stainless-OS 而非 X-Stainless-Os)
func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) {
if fp == nil {
return
@@ -181,27 +182,27 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) {
// 设置user-agent
if fp.UserAgent != "" {
- req.Header.Set("user-agent", fp.UserAgent)
+ setHeaderRaw(req.Header, "User-Agent", fp.UserAgent)
}
- // 设置x-stainless-*头
+ // 设置x-stainless-*头(保持与 claude.DefaultHeaders 一致的大小写)
if fp.StainlessLang != "" {
- req.Header.Set("X-Stainless-Lang", fp.StainlessLang)
+ setHeaderRaw(req.Header, "X-Stainless-Lang", fp.StainlessLang)
}
if fp.StainlessPackageVersion != "" {
- req.Header.Set("X-Stainless-Package-Version", fp.StainlessPackageVersion)
+ setHeaderRaw(req.Header, "X-Stainless-Package-Version", fp.StainlessPackageVersion)
}
if fp.StainlessOS != "" {
- req.Header.Set("X-Stainless-OS", fp.StainlessOS)
+ setHeaderRaw(req.Header, "X-Stainless-OS", fp.StainlessOS)
}
if fp.StainlessArch != "" {
- req.Header.Set("X-Stainless-Arch", fp.StainlessArch)
+ setHeaderRaw(req.Header, "X-Stainless-Arch", fp.StainlessArch)
}
if fp.StainlessRuntime != "" {
- req.Header.Set("X-Stainless-Runtime", fp.StainlessRuntime)
+ setHeaderRaw(req.Header, "X-Stainless-Runtime", fp.StainlessRuntime)
}
if fp.StainlessRuntimeVersion != "" {
- req.Header.Set("X-Stainless-Runtime-Version", fp.StainlessRuntimeVersion)
+ setHeaderRaw(req.Header, "X-Stainless-Runtime-Version", fp.StainlessRuntimeVersion)
}
}
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index 44d20491..1a24bad1 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -79,6 +79,20 @@ const backendModeCacheTTL = 60 * time.Second
const backendModeErrorTTL = 5 * time.Second
const backendModeDBTimeout = 5 * time.Second
+// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL)
+type cachedGatewayForwardingSettings struct {
+ fingerprintUnification bool
+ metadataPassthrough bool
+ expiresAt int64 // unix nano
+}
+
+var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings
+var gatewayForwardingSF singleflight.Group
+
+const gatewayForwardingCacheTTL = 60 * time.Second
+const gatewayForwardingErrorTTL = 5 * time.Second
+const gatewayForwardingDBTimeout = 5 * time.Second
+
// DefaultSubscriptionGroupReader validates group references used by default subscriptions.
type DefaultSubscriptionGroupReader interface {
GetByID(ctx context.Context, id int64) (*Group, error)
@@ -510,6 +524,10 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
// Backend Mode
updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled)
+ // Gateway forwarding behavior
+ updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification)
+ updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
+
err = s.settingRepo.SetMultiple(ctx, updates)
if err == nil {
// 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口
@@ -524,6 +542,12 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
value: settings.BackendModeEnabled,
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
})
+ gatewayForwardingSF.Forget("gateway_forwarding")
+ gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
+ fingerprintUnification: settings.EnableFingerprintUnification,
+ metadataPassthrough: settings.EnableMetadataPassthrough,
+ expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
+ })
if s.onUpdate != nil {
s.onUpdate() // Invalidate cache after settings update
}
@@ -626,6 +650,57 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
return false
}
+// GetGatewayForwardingSettings returns cached gateway forwarding settings.
+// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path.
+// Returns (fingerprintUnification, metadataPassthrough).
+func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough bool) {
+ if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
+ if time.Now().UnixNano() < cached.expiresAt {
+ return cached.fingerprintUnification, cached.metadataPassthrough
+ }
+ }
+ type gwfResult struct {
+ fp, mp bool
+ }
+ val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) {
+ if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
+ if time.Now().UnixNano() < cached.expiresAt {
+ return gwfResult{cached.fingerprintUnification, cached.metadataPassthrough}, nil
+ }
+ }
+ dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
+ defer cancel()
+ values, err := s.settingRepo.GetMultiple(dbCtx, []string{
+ SettingKeyEnableFingerprintUnification,
+ SettingKeyEnableMetadataPassthrough,
+ })
+ if err != nil {
+ slog.Warn("failed to get gateway forwarding settings", "error", err)
+ gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
+ fingerprintUnification: true,
+ metadataPassthrough: false,
+ expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
+ })
+ return gwfResult{true, false}, nil
+ }
+ fp := true
+ if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
+ fp = v == "true"
+ }
+ mp := values[SettingKeyEnableMetadataPassthrough] == "true"
+ gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
+ fingerprintUnification: fp,
+ metadataPassthrough: mp,
+ expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
+ })
+ return gwfResult{fp, mp}, nil
+ })
+ if r, ok := val.(gwfResult); ok {
+ return r.fp, r.mp
+ }
+ return true, false // fail-open defaults
+}
+
// IsEmailVerifyEnabled 检查是否开启邮件验证
func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled)
@@ -923,6 +998,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
// 分组隔离
result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true"
+ // Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false)
+ if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" {
+ result.EnableFingerprintUnification = v == "true"
+ } else {
+ result.EnableFingerprintUnification = true // default: enabled (current behavior)
+ }
+ result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true"
+
return result
}
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index cf1d5eed..4e29dba5 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -75,6 +75,10 @@ type SystemSettings struct {
// Backend 模式:禁用用户注册和自助服务,仅管理员可登录
BackendModeEnabled bool
+
+ // Gateway forwarding behavior
+ EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
+ EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
}
type DefaultSubscriptionSetting struct {
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index 83258bcc..196e3788 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -86,6 +86,10 @@ export interface SystemSettings {
// 分组隔离
allow_ungrouped_key_scheduling: boolean
+
+ // Gateway forwarding behavior
+ enable_fingerprint_unification: boolean
+ enable_metadata_passthrough: boolean
}
export interface UpdateSettingsRequest {
@@ -142,6 +146,8 @@ export interface UpdateSettingsRequest {
min_claude_code_version?: string
max_claude_code_version?: string
allow_ungrouped_key_scheduling?: boolean
+ enable_fingerprint_unification?: boolean
+ enable_metadata_passthrough?: boolean
}
/**
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index ad916a65..4353b14b 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -4171,6 +4171,14 @@ export default {
allowUngroupedKey: 'Allow Ungrouped Key Scheduling',
allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.'
},
+ gatewayForwarding: {
+ title: 'Request Forwarding',
+ description: 'Control how requests are forwarded to upstream OAuth accounts',
+ fingerprintUnification: 'Fingerprint Unification',
+ fingerprintUnificationHint: 'Unify X-Stainless-* headers across users sharing the same OAuth account. Disabling passes through each client\'s original headers.',
+ metadataPassthrough: 'Metadata Passthrough',
+ metadataPassthroughHint: 'Pass through client\'s original metadata.user_id without rewriting. May improve upstream cache hit rates.',
+ },
site: {
title: 'Site Settings',
description: 'Customize site branding',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 042fca26..6dac7fee 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -4334,6 +4334,14 @@ export default {
allowUngroupedKey: '允许未分组 Key 调度',
allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。'
},
+ gatewayForwarding: {
+ title: '请求转发行为',
+ description: '控制请求转发到上游 OAuth 账号时的行为',
+ fingerprintUnification: '指纹统一化',
+ fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。',
+ metadataPassthrough: 'Metadata 透传',
+ metadataPassthroughHint: '透传客户端原始 metadata.user_id,不进行重写。可能提高上游缓存命中率。',
+ },
site: {
title: '站点设置',
description: '自定义站点品牌',
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 00105eb9..0e510aa9 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -1171,6 +1171,45 @@
+
+
+